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