370 lines
12 KiB
Python
370 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import time
|
|
from datetime import datetime, timezone as dt_timezone
|
|
from urllib.parse import urlencode
|
|
|
|
from asgiref.sync import async_to_sync
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
from django.http import HttpResponseBadRequest, JsonResponse
|
|
from django.shortcuts import get_object_or_404, render
|
|
from django.urls import reverse
|
|
from django.utils import timezone as dj_timezone
|
|
from django.views import View
|
|
|
|
from core.clients import transport
|
|
from core.models import ChatSession, Message, Person, PersonIdentifier
|
|
|
|
|
|
def _default_service(service: str | None) -> str:
|
|
value = str(service or "").strip().lower()
|
|
if value in {"signal", "whatsapp", "instagram", "xmpp"}:
|
|
return value
|
|
return "signal"
|
|
|
|
|
|
def _safe_limit(raw) -> int:
|
|
try:
|
|
value = int(raw or 40)
|
|
except (TypeError, ValueError):
|
|
value = 40
|
|
return max(10, min(value, 200))
|
|
|
|
|
|
def _safe_after_ts(raw) -> int:
|
|
try:
|
|
value = int(raw or 0)
|
|
except (TypeError, ValueError):
|
|
value = 0
|
|
return max(0, value)
|
|
|
|
|
|
def _format_ts_label(ts_value: int) -> str:
|
|
try:
|
|
as_dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
|
|
return dj_timezone.localtime(as_dt).strftime("%H:%M")
|
|
except Exception:
|
|
return str(ts_value or "")
|
|
|
|
|
|
def _is_outgoing(msg: Message) -> bool:
|
|
return str(msg.custom_author or "").upper() in {"USER", "BOT"}
|
|
|
|
|
|
def _serialize_message(msg: Message) -> dict:
|
|
author = str(msg.custom_author or "").strip()
|
|
return {
|
|
"id": str(msg.id),
|
|
"ts": int(msg.ts or 0),
|
|
"display_ts": _format_ts_label(int(msg.ts or 0)),
|
|
"text": str(msg.text or ""),
|
|
"author": author,
|
|
"outgoing": _is_outgoing(msg),
|
|
}
|
|
|
|
|
|
def _context_base(user, service, identifier, person):
|
|
person_identifier = None
|
|
if person is not None:
|
|
person_identifier = (
|
|
PersonIdentifier.objects.filter(
|
|
user=user,
|
|
person=person,
|
|
service=service,
|
|
).first()
|
|
or PersonIdentifier.objects.filter(user=user, person=person).first()
|
|
)
|
|
if person_identifier is None and identifier:
|
|
person_identifier = PersonIdentifier.objects.filter(
|
|
user=user,
|
|
service=service,
|
|
identifier=identifier,
|
|
).first()
|
|
|
|
if person_identifier:
|
|
service = person_identifier.service
|
|
identifier = person_identifier.identifier
|
|
person = person_identifier.person
|
|
|
|
return {
|
|
"person_identifier": person_identifier,
|
|
"service": service,
|
|
"identifier": identifier,
|
|
"person": person,
|
|
}
|
|
|
|
|
|
def _compose_urls(service, identifier, person_id):
|
|
query = {"service": service, "identifier": identifier}
|
|
if person_id:
|
|
query["person"] = str(person_id)
|
|
payload = urlencode(query)
|
|
return {
|
|
"page_url": f"{reverse('compose_page')}?{payload}",
|
|
"widget_url": f"{reverse('compose_widget')}?{payload}",
|
|
}
|
|
|
|
|
|
def _load_messages(user, person_identifier, limit):
|
|
if person_identifier is None:
|
|
return {"session": None, "messages": []}
|
|
|
|
session, _ = ChatSession.objects.get_or_create(
|
|
user=user,
|
|
identifier=person_identifier,
|
|
)
|
|
messages = list(
|
|
Message.objects.filter(user=user, session=session)
|
|
.select_related("session", "session__identifier", "session__identifier__person")
|
|
.order_by("-ts")[:limit]
|
|
)
|
|
messages.reverse()
|
|
return {"session": session, "messages": messages}
|
|
|
|
|
|
def _panel_context(
|
|
request,
|
|
service: str,
|
|
identifier: str,
|
|
person: Person | None,
|
|
render_mode: str,
|
|
notice: str = "",
|
|
level: str = "success",
|
|
):
|
|
base = _context_base(request.user, service, identifier, person)
|
|
limit = _safe_limit(request.GET.get("limit") or request.POST.get("limit"))
|
|
session_bundle = _load_messages(request.user, base["person_identifier"], limit)
|
|
last_ts = 0
|
|
if session_bundle["messages"]:
|
|
last_ts = int(session_bundle["messages"][-1].ts or 0)
|
|
urls = _compose_urls(
|
|
base["service"],
|
|
base["identifier"],
|
|
base["person"].id if base["person"] else None,
|
|
)
|
|
|
|
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
|
|
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
|
|
|
|
return {
|
|
"service": base["service"],
|
|
"identifier": base["identifier"],
|
|
"person": base["person"],
|
|
"person_identifier": base["person_identifier"],
|
|
"session": session_bundle["session"],
|
|
"messages": session_bundle["messages"],
|
|
"serialized_messages": [
|
|
_serialize_message(msg) for msg in session_bundle["messages"]
|
|
],
|
|
"last_ts": last_ts,
|
|
"limit": limit,
|
|
"notice_message": notice,
|
|
"notice_level": level,
|
|
"render_mode": render_mode,
|
|
"compose_page_url": urls["page_url"],
|
|
"compose_widget_url": urls["widget_url"],
|
|
"ai_workspace_url": (
|
|
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
|
if base["person"]
|
|
else reverse("ai_workspace")
|
|
),
|
|
"manual_icon_class": "fa-solid fa-paper-plane",
|
|
"panel_id": f"compose-panel-{unique}",
|
|
}
|
|
|
|
|
|
class ComposeContactsDropdown(LoginRequiredMixin, View):
|
|
def get(self, request):
|
|
rows = list(
|
|
PersonIdentifier.objects.filter(user=request.user)
|
|
.select_related("person")
|
|
.order_by("person__name", "service", "identifier")
|
|
)
|
|
items = []
|
|
for row in rows:
|
|
urls = _compose_urls(row.service, row.identifier, row.person_id)
|
|
items.append(
|
|
{
|
|
"person_name": row.person.name,
|
|
"service": row.service,
|
|
"identifier": row.identifier,
|
|
"compose_url": urls["page_url"],
|
|
}
|
|
)
|
|
return render(
|
|
request,
|
|
"partials/nav-contacts-dropdown.html",
|
|
{
|
|
"items": items,
|
|
"manual_icon_class": "fa-solid fa-paper-plane",
|
|
},
|
|
)
|
|
|
|
|
|
class ComposePage(LoginRequiredMixin, View):
|
|
template_name = "pages/compose.html"
|
|
|
|
def get(self, request):
|
|
service = _default_service(request.GET.get("service"))
|
|
identifier = str(request.GET.get("identifier") or "").strip()
|
|
person = None
|
|
person_id = request.GET.get("person")
|
|
if person_id:
|
|
person = get_object_or_404(Person, id=person_id, user=request.user)
|
|
if not identifier and person is None:
|
|
return HttpResponseBadRequest("Missing contact identifier.")
|
|
|
|
context = _panel_context(
|
|
request=request,
|
|
service=service,
|
|
identifier=identifier,
|
|
person=person,
|
|
render_mode="page",
|
|
)
|
|
return render(request, self.template_name, context)
|
|
|
|
|
|
class ComposeWidget(LoginRequiredMixin, View):
|
|
def get(self, request):
|
|
service = _default_service(request.GET.get("service"))
|
|
identifier = str(request.GET.get("identifier") or "").strip()
|
|
person = None
|
|
person_id = request.GET.get("person")
|
|
if person_id:
|
|
person = get_object_or_404(Person, id=person_id, user=request.user)
|
|
if not identifier and person is None:
|
|
return HttpResponseBadRequest("Missing contact identifier.")
|
|
|
|
panel_context = _panel_context(
|
|
request=request,
|
|
service=service,
|
|
identifier=identifier,
|
|
person=person,
|
|
render_mode="widget",
|
|
)
|
|
title_name = (
|
|
panel_context["person"].name
|
|
if panel_context["person"] is not None
|
|
else panel_context["identifier"]
|
|
)
|
|
context = {
|
|
"title": f"Manual Chat: {title_name}",
|
|
"unique": f"compose-{panel_context['panel_id']}",
|
|
"window_content": "partials/compose-panel.html",
|
|
"widget_options": 'gs-w="6" gs-h="12" gs-x="0" gs-y="0" gs-min-w="4"',
|
|
**panel_context,
|
|
}
|
|
return render(request, "mixins/wm/widget.html", context)
|
|
|
|
|
|
class ComposeThread(LoginRequiredMixin, View):
|
|
def get(self, request):
|
|
service = _default_service(request.GET.get("service"))
|
|
identifier = str(request.GET.get("identifier") or "").strip()
|
|
person = None
|
|
person_id = request.GET.get("person")
|
|
if person_id:
|
|
person = get_object_or_404(Person, id=person_id, user=request.user)
|
|
if not identifier and person is None:
|
|
return HttpResponseBadRequest("Missing contact identifier.")
|
|
|
|
limit = _safe_limit(request.GET.get("limit") or 60)
|
|
after_ts = _safe_after_ts(request.GET.get("after_ts"))
|
|
base = _context_base(request.user, service, identifier, person)
|
|
latest_ts = after_ts
|
|
messages = []
|
|
if base["person_identifier"] is not None:
|
|
session, _ = ChatSession.objects.get_or_create(
|
|
user=request.user,
|
|
identifier=base["person_identifier"],
|
|
)
|
|
queryset = Message.objects.filter(user=request.user, session=session)
|
|
if after_ts > 0:
|
|
queryset = queryset.filter(ts__gt=after_ts)
|
|
messages = list(
|
|
queryset.select_related(
|
|
"session",
|
|
"session__identifier",
|
|
"session__identifier__person",
|
|
)
|
|
.order_by("ts")[:limit]
|
|
)
|
|
newest = (
|
|
Message.objects.filter(user=request.user, session=session)
|
|
.order_by("-ts")
|
|
.values_list("ts", flat=True)
|
|
.first()
|
|
)
|
|
if newest:
|
|
latest_ts = max(latest_ts, int(newest))
|
|
payload = {
|
|
"messages": [_serialize_message(msg) for msg in messages],
|
|
"last_ts": latest_ts,
|
|
}
|
|
return JsonResponse(payload)
|
|
|
|
|
|
class ComposeSend(LoginRequiredMixin, View):
|
|
def post(self, request):
|
|
service = _default_service(request.POST.get("service"))
|
|
identifier = str(request.POST.get("identifier") or "").strip()
|
|
person = None
|
|
person_id = request.POST.get("person")
|
|
if person_id:
|
|
person = get_object_or_404(Person, id=person_id, user=request.user)
|
|
render_mode = str(request.POST.get("render_mode") or "page").strip().lower()
|
|
if render_mode not in {"page", "widget"}:
|
|
render_mode = "page"
|
|
|
|
if not identifier and person is None:
|
|
return HttpResponseBadRequest("Missing contact identifier.")
|
|
|
|
text = str(request.POST.get("text") or "").strip()
|
|
if not text:
|
|
return render(
|
|
request,
|
|
"partials/compose-send-status.html",
|
|
{"notice_message": "Message is empty.", "notice_level": "danger"},
|
|
)
|
|
|
|
base = _context_base(request.user, service, identifier, person)
|
|
ts = async_to_sync(transport.send_message_raw)(
|
|
base["service"],
|
|
base["identifier"],
|
|
text=text,
|
|
attachments=[],
|
|
)
|
|
if not ts:
|
|
return render(
|
|
request,
|
|
"partials/compose-send-status.html",
|
|
{
|
|
"notice_message": "Send failed. Check service account state.",
|
|
"notice_level": "danger",
|
|
},
|
|
)
|
|
|
|
if base["person_identifier"] is not None:
|
|
session, _ = ChatSession.objects.get_or_create(
|
|
user=request.user,
|
|
identifier=base["person_identifier"],
|
|
)
|
|
Message.objects.create(
|
|
user=request.user,
|
|
session=session,
|
|
sender_uuid="",
|
|
text=text,
|
|
ts=int(ts) if str(ts).isdigit() else int(time.time() * 1000),
|
|
delivered_ts=int(ts) if str(ts).isdigit() else None,
|
|
custom_author="USER",
|
|
)
|
|
|
|
response = render(
|
|
request,
|
|
"partials/compose-send-status.html",
|
|
{"notice_message": "Sent.", "notice_level": "success"},
|
|
)
|
|
response["HX-Trigger"] = "composeMessageSent"
|
|
return response
|