from __future__ import annotations import hashlib import re 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.core import signing from django.core.cache import cache 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.messaging import ai as ai_runner from core.messaging.utils import messages_to_string from core.models import ( AI, ChatSession, Message, PatternMitigationPlan, Person, PersonIdentifier, ) from core.views.workspace import _build_engage_payload, _parse_draft_options COMPOSE_WS_TOKEN_SALT = "compose-ws" COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage" COMPOSE_AI_CACHE_TTL = 60 * 30 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 _owner_name(user) -> str: return ( user.first_name or user.get_full_name().strip() or user.username or "Me" ) def _compose_ws_token(user_id, service, identifier, person_id): payload = { "u": int(user_id), "s": str(service or ""), "i": str(identifier or ""), "p": str(person_id) if person_id else "", "exp": int(time.time()) + (60 * 60 * 12), } return signing.dumps(payload, salt=COMPOSE_WS_TOKEN_SALT) def _compose_ai_cache_key(kind, user_id, service, identifier, person_id, last_ts, limit): raw = "|".join( [ str(kind or ""), str(user_id), str(service or ""), str(identifier or ""), str(person_id or ""), str(last_ts or 0), str(limit or 0), ] ) digest = hashlib.sha1(raw.encode("utf-8")).hexdigest() return f"compose:{kind}:{digest}" def _plain_text(value): cleaned = re.sub(r"\s+", " ", str(value or "").strip()) cleaned = re.sub(r"^\s*#{1,6}\s*", "", cleaned) cleaned = re.sub(r"\*\*(.*?)\*\*", r"\1", cleaned) cleaned = re.sub(r"`(.*?)`", r"\1", cleaned) return cleaned.strip() def _engage_body_only(value): lines = [line.strip() for line in str(value or "").splitlines() if line.strip()] if lines and lines[0].startswith("**"): lines = lines[1:] if lines and lines[0].lower() == "guidance:": lines = lines[1:] return _plain_text(" ".join(lines)) def _messages_for_ai(user, person_identifier, limit): if person_identifier is None: return [] session, _ = ChatSession.objects.get_or_create(user=user, identifier=person_identifier) rows = list( Message.objects.filter(user=user, session=session) .select_related("session", "session__identifier", "session__identifier__person") .order_by("-ts")[:limit] ) rows.reverse() return rows def _fallback_drafts(): return [ { "label": "Soft", "text": "I want us to stay connected. I am listening and I want to understand your perspective clearly.", }, { "label": "Neutral", "text": "I hear your point. Let us clarify what each of us means so we can move forward constructively.", }, { "label": "Firm", "text": "I want to resolve this respectfully. I will continue when we can keep the conversation constructive.", }, ] def _build_draft_prompt(owner_name, person_name, transcript): return [ { "role": "system", "content": ( "Generate exactly three short reply drafts for a chat. " "Return labels Soft, Neutral, Firm. " "Format:\nSoft: ...\nNeutral: ...\nFirm: ...\n" "Each draft must be one to two sentences, plain text, no markdown." ), }, { "role": "user", "content": ( f"Me: {owner_name}\n" f"Other: {person_name}\n" f"Conversation:\n{transcript}" ), }, ] def _build_summary_prompt(owner_name, person_name, transcript): return [ { "role": "system", "content": ( "Create a concise conversation summary with three sections. " "Use this exact structure:\n" "Headlines:\n- ...\n" "Patterns:\n- ...\n" "Suggested Next Message:\n- ...\n" "Keep each bullet practical and specific." ), }, { "role": "user", "content": ( f"Me: {owner_name}\n" f"Other: {person_name}\n" f"Conversation:\n{transcript}" ), }, ] def _build_engage_prompt(owner_name, person_name, transcript): return [ { "role": "system", "content": ( "Write one short de-escalating outreach in shared framing. " "Use 'we/us/our' only. No names. One or two sentences." ), }, { "role": "user", "content": ( f"Me: {owner_name}\n" f"Other: {person_name}\n" f"Conversation:\n{transcript}" ), }, ] def _latest_plan_for_person(user, person): if person is None: return None conversation = ( PatternMitigationPlan.objects.filter( user=user, conversation__participants=person, ) .select_related("conversation") .order_by("-updated_at") .first() ) return conversation def _best_engage_source(plan): if plan is None: return (None, "") correction = plan.corrections.order_by("-created_at").first() if correction: return (correction, "correction") rule = plan.rules.order_by("-created_at").first() if rule: return (rule, "rule") game = plan.games.order_by("-created_at").first() if game: return (game, "game") return (None, "") 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, ) ws_token = _compose_ws_token( user_id=request.user.id, service=base["service"], identifier=base["identifier"], person_id=base["person"].id if base["person"] else None, ) ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}" 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"], "compose_drafts_url": reverse("compose_drafts"), "compose_summary_url": reverse("compose_summary"), "compose_engage_preview_url": reverse("compose_engage_preview"), "compose_engage_send_url": reverse("compose_engage_send"), "compose_ws_url": ws_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 ComposeDrafts(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 JsonResponse({"ok": False, "error": "Missing contact identifier."}) base = _context_base(request.user, service, identifier, person) limit = _safe_limit(request.GET.get("limit") or 60) messages = _messages_for_ai(request.user, base["person_identifier"], limit) if not messages: return JsonResponse( { "ok": True, "cached": False, "drafts": _fallback_drafts(), } ) last_ts = int(messages[-1].ts or 0) cache_key = _compose_ai_cache_key( "drafts", request.user.id, base["service"], base["identifier"], base["person"].id if base["person"] else "", last_ts, limit, ) cached = cache.get(cache_key) if cached: return JsonResponse({"ok": True, "cached": True, "drafts": cached}) ai_obj = AI.objects.filter(user=request.user).first() transcript = messages_to_string( messages, author_rewrites={ "USER": _owner_name(request.user), "BOT": "Assistant", }, ) drafts = _fallback_drafts() if ai_obj is not None: try: result = async_to_sync(ai_runner.run_prompt)( _build_draft_prompt( owner_name=_owner_name(request.user), person_name=base["person"].name if base["person"] else "Other", transcript=transcript, ), ai_obj, ) parsed = _parse_draft_options(result) if parsed: drafts = parsed except Exception: pass cache.set(cache_key, drafts, timeout=COMPOSE_AI_CACHE_TTL) return JsonResponse({"ok": True, "cached": False, "drafts": drafts}) class ComposeSummary(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 JsonResponse({"ok": False, "error": "Missing contact identifier."}) base = _context_base(request.user, service, identifier, person) limit = _safe_limit(request.GET.get("limit") or 60) messages = _messages_for_ai(request.user, base["person_identifier"], limit) if not messages: return JsonResponse({"ok": True, "cached": False, "summary": ""}) last_ts = int(messages[-1].ts or 0) cache_key = _compose_ai_cache_key( "summary", request.user.id, base["service"], base["identifier"], base["person"].id if base["person"] else "", last_ts, limit, ) cached = cache.get(cache_key) if cached: return JsonResponse({"ok": True, "cached": True, "summary": cached}) ai_obj = AI.objects.filter(user=request.user).first() transcript = messages_to_string( messages, author_rewrites={ "USER": _owner_name(request.user), "BOT": "Assistant", }, ) if ai_obj is None: fallback = ( "Headlines:\n" "- Conversation loaded.\n" "Patterns:\n" "- Not enough AI context configured yet.\n" "Suggested Next Message:\n" "- I want us to keep this clear and constructive." ) cache.set(cache_key, fallback, timeout=COMPOSE_AI_CACHE_TTL) return JsonResponse({"ok": True, "cached": False, "summary": fallback}) try: summary = async_to_sync(ai_runner.run_prompt)( _build_summary_prompt( owner_name=_owner_name(request.user), person_name=base["person"].name if base["person"] else "Other", transcript=transcript, ), ai_obj, ) except Exception as exc: return JsonResponse({"ok": False, "error": str(exc)}) summary = str(summary or "").strip() cache.set(cache_key, summary, timeout=COMPOSE_AI_CACHE_TTL) return JsonResponse({"ok": True, "cached": False, "summary": summary}) class ComposeEngagePreview(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 JsonResponse({"ok": False, "error": "Missing contact identifier."}) base = _context_base(request.user, service, identifier, person) limit = _safe_limit(request.GET.get("limit") or 60) messages = _messages_for_ai(request.user, base["person_identifier"], limit) transcript = messages_to_string( messages, author_rewrites={ "USER": _owner_name(request.user), "BOT": "Assistant", }, ) owner_name = _owner_name(request.user) recipient_name = base["person"].name if base["person"] else "Other" plan = _latest_plan_for_person(request.user, base["person"]) source_obj, source_kind = _best_engage_source(plan) preview = "" outbound = "" artifact_label = "AI-generated" if source_obj is not None: payload = _build_engage_payload( source_obj=source_obj, source_kind=source_kind, share_target="other", framing="shared", context_note="", owner_name=owner_name, recipient_name=recipient_name, ) preview = str(payload.get("preview") or "").strip() outbound = _engage_body_only(payload.get("outbound") or "") artifact_label = f"{source_kind.title()}: {getattr(source_obj, 'title', '')}" else: ai_obj = AI.objects.filter(user=request.user).first() if ai_obj is not None: try: generated = async_to_sync(ai_runner.run_prompt)( _build_engage_prompt(owner_name, recipient_name, transcript), ai_obj, ) outbound = _plain_text(generated) except Exception: outbound = "" if not outbound: outbound = ( "We should slow down, clarify what we mean, and respond with care." ) preview = f"**Shared Engage** (Correction)\n\nGuidance:\n{outbound}" token = signing.dumps( { "u": request.user.id, "s": base["service"], "i": base["identifier"], "p": str(base["person"].id) if base["person"] else "", "outbound": outbound, "exp": int(time.time()) + (60 * 10), }, salt=COMPOSE_ENGAGE_TOKEN_SALT, ) return JsonResponse( { "ok": True, "preview": preview, "outbound": outbound, "token": token, "artifact": artifact_label, } ) class ComposeEngageSend(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) if not identifier and person is None: return JsonResponse({"ok": False, "error": "Missing contact identifier."}) failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip() failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip() if failsafe_arm != "1" or failsafe_confirm != "1": return JsonResponse( {"ok": False, "error": "Enable both send safety switches first."} ) token = str(request.POST.get("engage_token") or "").strip() if not token: return JsonResponse({"ok": False, "error": "Missing engage token."}) try: payload = signing.loads(token, salt=COMPOSE_ENGAGE_TOKEN_SALT) except Exception: return JsonResponse({"ok": False, "error": "Invalid engage token."}) if int(payload.get("u") or 0) != int(request.user.id): return JsonResponse({"ok": False, "error": "Token does not match user."}) if int(payload.get("exp") or 0) < int(time.time()): return JsonResponse({"ok": False, "error": "Engage token expired."}) outbound = str(payload.get("outbound") or "").strip() if not outbound: return JsonResponse({"ok": False, "error": "Empty engage payload."}) base = _context_base(request.user, service, identifier, person) ts = async_to_sync(transport.send_message_raw)( base["service"], base["identifier"], text=outbound, attachments=[], ) if not ts: return JsonResponse({"ok": False, "error": "Send failed."}) if base["person_identifier"] is not None: session, _ = ChatSession.objects.get_or_create( user=request.user, identifier=base["person_identifier"], ) ts_value = int(ts) if str(ts).isdigit() else int(time.time() * 1000) Message.objects.create( user=request.user, session=session, sender_uuid="", text=outbound, ts=ts_value, delivered_ts=ts_value if str(ts).isdigit() else None, custom_author="USER", ) return JsonResponse({"ok": True, "message": "Shared engage sent."}) 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.") failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip() failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip() if failsafe_arm != "1" or failsafe_confirm != "1": return render( request, "partials/compose-send-status.html", { "notice_message": "Enable both send safety switches before sending.", "notice_level": "warning", }, ) 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