diff --git a/app/local_settings.py b/app/local_settings.py index f9aa407..ef0962e 100644 --- a/app/local_settings.py +++ b/app/local_settings.py @@ -48,6 +48,13 @@ if DEBUG: SETTINGS_EXPORT = ["BILLING_ENABLED"] SIGNAL_NUMBER = getenv("SIGNAL_NUMBER") +SIGNAL_HTTP_URL = getenv("SIGNAL_HTTP_URL", "http://signal:8080") + +WHATSAPP_ENABLED = getenv("WHATSAPP_ENABLED", "false").lower() in trues +WHATSAPP_HTTP_URL = getenv("WHATSAPP_HTTP_URL", "http://whatsapp:8080") + +INSTAGRAM_ENABLED = getenv("INSTAGRAM_ENABLED", "false").lower() in trues +INSTAGRAM_HTTP_URL = getenv("INSTAGRAM_HTTP_URL", "http://instagram:8080") XMPP_ADDRESS = getenv("XMPP_ADDRESS") XMPP_JID = getenv("XMPP_JID") diff --git a/app/urls.py b/app/urls.py index bedf419..7b97b61 100644 --- a/app/urls.py +++ b/app/urls.py @@ -18,14 +18,15 @@ from django.conf.urls.static import static from django.contrib import admin from django.contrib.auth.views import LogoutView from django.urls import include, path -from django.views.generic import TemplateView from two_factor.urls import urlpatterns as tf_urls from core.views import ( ais, base, + compose, groups, identifiers, + instagram, manipulations, messages, notifications, @@ -34,6 +35,7 @@ from core.views import ( queues, sessions, signal, + whatsapp, workspace, ) @@ -56,11 +58,31 @@ urlpatterns = [ signal.Signal.as_view(), name="signal", ), + path( + "services/whatsapp/", + whatsapp.WhatsApp.as_view(), + name="whatsapp", + ), + path( + "services/instagram/", + instagram.Instagram.as_view(), + name="instagram", + ), path( "services/signal//", signal.SignalAccounts.as_view(), name="signal_accounts", ), + path( + "services/whatsapp//", + whatsapp.WhatsAppAccounts.as_view(), + name="whatsapp_accounts", + ), + path( + "services/instagram//", + instagram.InstagramAccounts.as_view(), + name="instagram_accounts", + ), path( "services/signal//contacts//", signal.SignalContactsList.as_view(), @@ -81,6 +103,41 @@ urlpatterns = [ signal.SignalAccountAdd.as_view(), name="signal_account_add", ), + path( + "services/whatsapp//add/", + whatsapp.WhatsAppAccountAdd.as_view(), + name="whatsapp_account_add", + ), + path( + "services/instagram//add/", + instagram.InstagramAccountAdd.as_view(), + name="instagram_account_add", + ), + path( + "compose/page/", + compose.ComposePage.as_view(), + name="compose_page", + ), + path( + "compose/widget/", + compose.ComposeWidget.as_view(), + name="compose_widget", + ), + path( + "compose/send/", + compose.ComposeSend.as_view(), + name="compose_send", + ), + path( + "compose/thread/", + compose.ComposeThread.as_view(), + name="compose_thread", + ), + path( + "compose/widget/contacts/", + compose.ComposeContactsDropdown.as_view(), + name="compose_contacts_dropdown", + ), # AIs path( "ai/workspace/", @@ -97,6 +154,21 @@ urlpatterns = [ workspace.AIWorkspacePersonWidget.as_view(), name="ai_workspace_person", ), + path( + "ai/workspace//person//insights/graphs/", + workspace.AIWorkspaceInsightGraphs.as_view(), + name="ai_workspace_insight_graphs", + ), + path( + "ai/workspace//person//insights/help/", + workspace.AIWorkspaceInsightHelp.as_view(), + name="ai_workspace_insight_help", + ), + path( + "ai/workspace//person//insights//", + workspace.AIWorkspaceInsightDetail.as_view(), + name="ai_workspace_insight_detail", + ), path( "ai/workspace//person//run//", workspace.AIWorkspaceRunOperation.as_view(), @@ -118,50 +190,65 @@ urlpatterns = [ name="ai_workspace_mitigation_create", ), path( - "ai/workspace//person//mitigation//chat/", + "ai/workspace//person//mitigation/" + "/chat/", workspace.AIWorkspaceMitigationChat.as_view(), name="ai_workspace_mitigation_chat", ), path( - "ai/workspace//person//mitigation//export/", + "ai/workspace//person//mitigation/" + "/export/", workspace.AIWorkspaceExportArtifact.as_view(), name="ai_workspace_mitigation_export", ), path( - "ai/workspace//person//mitigation//artifact/create//", + "ai/workspace//person//mitigation/" + "/artifact/create//", workspace.AIWorkspaceCreateArtifact.as_view(), name="ai_workspace_mitigation_artifact_create", ), path( - "ai/workspace//person//mitigation//artifact///save/", + "ai/workspace//person//mitigation/" + "/artifact///save/", workspace.AIWorkspaceUpdateArtifact.as_view(), name="ai_workspace_mitigation_artifact_save", ), path( - "ai/workspace//person//mitigation//artifact///delete/", + "ai/workspace//person//mitigation/" + "/artifact///delete/", workspace.AIWorkspaceDeleteArtifact.as_view(), name="ai_workspace_mitigation_artifact_delete", ), path( - "ai/workspace//person//mitigation//artifact//delete-all/", + "ai/workspace//person//mitigation/" + "/artifact//delete-all/", workspace.AIWorkspaceDeleteArtifactList.as_view(), name="ai_workspace_mitigation_artifact_delete_all", ), path( - "ai/workspace//person//mitigation//engage/share/", + "ai/workspace//person//mitigation/" + "/engage/share/", workspace.AIWorkspaceEngageShare.as_view(), name="ai_workspace_mitigation_engage_share", ), path( - "ai/workspace//person//mitigation//auto/", + "ai/workspace//person//mitigation/" + "/auto/", workspace.AIWorkspaceAutoSettings.as_view(), name="ai_workspace_mitigation_auto", ), path( - "ai/workspace//person//mitigation//fundamentals/save/", + "ai/workspace//person//mitigation/" + "/fundamentals/save/", workspace.AIWorkspaceUpdateFundamentals.as_view(), name="ai_workspace_mitigation_fundamentals_save", ), + path( + "ai/workspace//person//mitigation/" + "/meta/save/", + workspace.AIWorkspaceUpdatePlanMeta.as_view(), + name="ai_workspace_mitigation_meta_save", + ), path( "ai//", ais.AIList.as_view(), diff --git a/auth_django.py b/auth_django.py index 856971c..d3e86cf 100755 --- a/auth_django.py +++ b/auth_django.py @@ -17,8 +17,8 @@ def log(data): os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") # Adjust if needed django.setup() -from django.contrib.auth import authenticate -from django.contrib.auth.models import User +from django.contrib.auth import authenticate # noqa: E402 +from django.contrib.auth.models import User # noqa: E402 def check_credentials(username, password): @@ -52,11 +52,11 @@ def main(): if command == "auth": if password and check_credentials(username, password): - log(f"Authentication success") + log("Authentication success") log("Sent 1") print("1", flush=True) # Success else: - log(f"Authentication failure") + log("Authentication failure") log("Sent 0") print("0", flush=True) # Failure diff --git a/core/__init__.py b/core/__init__.py index 15764c8..f7f7c23 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,7 +1,6 @@ import os # import stripe -from django.conf import settings os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" # from redis import StrictRedis diff --git a/core/clients/__init__.py b/core/clients/__init__.py index fe5350f..d04b9f5 100644 --- a/core/clients/__init__.py +++ b/core/clients/__init__.py @@ -21,19 +21,19 @@ class ClientBase(ABC): # ... async def message_received(self, *args, **kwargs): - self.ur.message_received(self.service, *args, **kwargs) + await self.ur.message_received(self.service, *args, **kwargs) async def message_read(self, *args, **kwargs): - self.ur.message_read(self.service, *args, **kwargs) + await self.ur.message_read(self.service, *args, **kwargs) async def started_typing(self, *args, **kwargs): - self.ur.started_typing(self.service, *args, **kwargs) + await self.ur.started_typing(self.service, *args, **kwargs) async def stopped_typing(self, *args, **kwargs): - self.ur.stopped_typing(self.service, *args, **kwargs) + await self.ur.stopped_typing(self.service, *args, **kwargs) async def reacted(self, *args, **kwargs): - self.ur.reacted(self.service, *args, **kwargs) + await self.ur.reacted(self.service, *args, **kwargs) async def replied(self, *args, **kwargs): - self.ur.replied(self.service, *args, **kwargs) + await self.ur.replied(self.service, *args, **kwargs) diff --git a/core/clients/gateway.py b/core/clients/gateway.py new file mode 100644 index 0000000..cccc3c0 --- /dev/null +++ b/core/clients/gateway.py @@ -0,0 +1,185 @@ +import asyncio +import time + +import aiohttp +from asgiref.sync import sync_to_async +from django.conf import settings + +from core.clients import ClientBase, transport +from core.messaging import history +from core.modules.mixed_protocol import normalize_gateway_event +from core.models import PersonIdentifier + + +class GatewayClient(ClientBase): + """ + Generic gateway-backed client for mixed protocol services. + + Expected gateway contract: + - GET /v1/events/next -> JSON event (or 204 for no events) + - POST /v1/send + - POST /v1/typing/start + - POST /v1/typing/stop + """ + + poll_interval_seconds = 1 + + def __init__(self, ur, loop, service): + super().__init__(ur, loop, service) + self._task = None + self._stopping = False + self._not_found_count = 0 + self.base_url = str( + getattr( + settings, + f"{service.upper()}_HTTP_URL", + f"http://{service}:8080", + ) + ).rstrip("/") + self.enabled = bool( + str( + getattr(settings, f"{service.upper()}_ENABLED", "true"), + ).lower() + in {"1", "true", "yes", "on"} + ) + + def start(self): + if not self.enabled: + self.log.info("%s gateway disabled by settings", self.service) + return + if self._task is None: + self.log.info("%s gateway client starting (%s)", self.service, self.base_url) + self._task = self.loop.create_task(self._poll_loop()) + + async def start_typing(self, identifier): + return await transport.start_typing(self.service, identifier) + + async def stop_typing(self, identifier): + return await transport.stop_typing(self.service, identifier) + + async def _gateway_next_event(self): + url = f"{self.base_url}/v1/events/next" + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url) as response: + if response.status == 204: + return "empty", None + if response.status == 404: + return "not_found", None + if response.status != 200: + return "error", None + try: + return "ok", await response.json() + except Exception: + return "error", None + + async def _poll_loop(self): + while not self._stopping: + try: + status, event = await self._gateway_next_event() + if status == "ok" and event: + self._not_found_count = 0 + await self._handle_event(event) + elif status == "not_found": + self._not_found_count += 1 + if self._not_found_count >= 3: + self.log.warning( + "%s gateway endpoint /v1/events/next returned 404 repeatedly; stopping client. " + "Set %s_ENABLED=false or configure %s_HTTP_URL.", + self.service, + self.service.upper(), + self.service.upper(), + ) + self._stopping = True + break + await asyncio.sleep(self.poll_interval_seconds) + elif status in {"empty", "error"}: + await asyncio.sleep(self.poll_interval_seconds) + except asyncio.CancelledError: + raise + except Exception as exc: + self.log.warning("%s gateway poll error: %s", self.service, exc) + await asyncio.sleep(max(2, self.poll_interval_seconds)) + + async def _handle_event(self, event): + normalized = normalize_gateway_event(self.service, event) + event_type = normalized.event_type + if event_type == "message": + await self._handle_message(normalized) + return + if event_type == "read": + await self.ur.message_read( + self.service, + identifier=normalized.identifier, + message_timestamps=normalized.message_timestamps, + read_ts=normalized.payload.get("read_ts"), + read_by=normalized.payload.get("read_by") + or normalized.payload.get("reader") + or normalized.identifier, + payload=normalized.payload, + ) + return + if event_type in {"typing_start", "typing_started"}: + await self.ur.started_typing( + self.service, + identifier=normalized.identifier, + payload=normalized.payload, + ) + return + if event_type in {"typing_stop", "typing_stopped"}: + await self.ur.stopped_typing( + self.service, + identifier=normalized.identifier, + payload=normalized.payload, + ) + return + + async def _handle_message(self, event): + identifier_value = event.identifier + if not identifier_value: + return + + text = event.text + ts = int(event.ts or int(time.time() * 1000)) + attachments = event.attachments + identifiers = await sync_to_async(list)( + PersonIdentifier.objects.filter( + identifier=identifier_value, + service=self.service, + ) + ) + if not identifiers: + return + + xmpp_attachments = [] + if attachments: + fetched = await asyncio.gather( + *[transport.fetch_attachment(self.service, att) for att in attachments] + ) + for row in fetched: + if row: + xmpp_attachments.append(row) + + for identifier in identifiers: + session = await history.get_chat_session(identifier.user, identifier) + await history.store_message( + session=session, + sender=identifier_value, + text=text, + ts=ts, + outgoing=False, + ) + await self.ur.xmpp.client.send_from_external( + identifier.user, + identifier, + text, + is_outgoing_message=False, + attachments=xmpp_attachments, + ) + await self.ur.message_received( + self.service, + identifier=identifier, + text=text, + ts=ts, + payload=event.payload, + ) diff --git a/core/clients/instagram.py b/core/clients/instagram.py new file mode 100644 index 0000000..fcf76be --- /dev/null +++ b/core/clients/instagram.py @@ -0,0 +1,6 @@ +from core.clients.gateway import GatewayClient + + +class InstagramClient(GatewayClient): + def __init__(self, ur, loop, service="instagram"): + super().__init__(ur, loop, service) diff --git a/core/clients/serviceapi.py b/core/clients/serviceapi.py new file mode 100644 index 0000000..85ee413 --- /dev/null +++ b/core/clients/serviceapi.py @@ -0,0 +1,8 @@ +""" +Backward-compatible compatibility layer. + +Prefer importing from `core.clients.transport`. +""" + +from core.clients.transport import * # noqa: F401,F403 + diff --git a/core/clients/signal.py b/core/clients/signal.py index 0a14ac1..10de7f2 100644 --- a/core/clients/signal.py +++ b/core/clients/signal.py @@ -1,5 +1,6 @@ import asyncio import json +from urllib.parse import urlparse import aiohttp from asgiref.sync import sync_to_async @@ -15,12 +16,19 @@ from core.util import logs log = logs.get_logger("signalF") -if settings.DEBUG: - SIGNAL_HOST = "127.0.0.1" +_signal_http_url = getattr(settings, "SIGNAL_HTTP_URL", "").strip() +if _signal_http_url: + parsed = urlparse( + _signal_http_url if "://" in _signal_http_url else f"http://{_signal_http_url}" + ) + SIGNAL_HOST = parsed.hostname or "signal" + SIGNAL_PORT = parsed.port or 8080 else: - SIGNAL_HOST = "signal" - -SIGNAL_PORT = 8080 + if settings.DEBUG: + SIGNAL_HOST = "127.0.0.1" + else: + SIGNAL_HOST = "signal" + SIGNAL_PORT = 8080 SIGNAL_URL = f"{SIGNAL_HOST}:{SIGNAL_PORT}" @@ -103,6 +111,36 @@ def _extract_attachments(raw_payload): return results +def _extract_receipt_timestamps(receipt_payload): + raw_ts = receipt_payload.get("timestamp") + if raw_ts is None: + raw_ts = receipt_payload.get("timestamps") + if isinstance(raw_ts, list): + out = [] + for item in raw_ts: + try: + out.append(int(item)) + except Exception: + continue + return out + if raw_ts is not None: + try: + return [int(raw_ts)] + except Exception: + return [] + return [] + + +def _typing_started(typing_payload): + action = str(typing_payload.get("action") or "").strip().lower() + if action in {"started", "start", "typing", "composing"}: + return True + explicit = typing_payload.get("isTyping") + if isinstance(explicit, bool): + return explicit + return True + + class NewSignalBot(SignalBot): def __init__(self, ur, service, config): self.ur = ur @@ -221,16 +259,53 @@ class HandleMessage(Command): log.warning("No Signal identifier available for message routing.") return - # Handle attachments across multiple Signal payload variants. - attachment_list = _extract_attachments(raw) - - # Get users/person identifiers for this Signal sender/recipient. + # Resolve person identifiers once for this event. identifiers = await sync_to_async(list)( PersonIdentifier.objects.filter( identifier=identifier_uuid, service=self.service, ) ) + + envelope = raw.get("envelope", {}) + typing_payload = envelope.get("typingMessage") + if isinstance(typing_payload, dict): + for identifier in identifiers: + if _typing_started(typing_payload): + await self.ur.started_typing( + self.service, + identifier=identifier, + payload=typing_payload, + ) + else: + await self.ur.stopped_typing( + self.service, + identifier=identifier, + payload=typing_payload, + ) + return + + receipt_payload = envelope.get("receiptMessage") + if isinstance(receipt_payload, dict): + read_timestamps = _extract_receipt_timestamps(receipt_payload) + read_ts = ( + envelope.get("timestamp") + or envelope.get("serverReceivedTimestamp") + or c.message.timestamp + ) + for identifier in identifiers: + await self.ur.message_read( + self.service, + identifier=identifier, + message_timestamps=read_timestamps, + read_ts=read_ts, + payload=receipt_payload, + read_by=source_uuid, + ) + return + + # Handle attachments across multiple Signal payload variants. + attachment_list = _extract_attachments(raw) xmpp_attachments = [] # Asynchronously fetch all attachments diff --git a/core/clients/signalapi.py b/core/clients/signalapi.py index 6a38923..37c4f71 100644 --- a/core/clients/signalapi.py +++ b/core/clients/signalapi.py @@ -5,7 +5,6 @@ import aiohttp import orjson import requests from django.conf import settings -from requests.exceptions import RequestException from rest_framework import status @@ -57,12 +56,12 @@ async def download_and_encode_base64(file_url, filename, content_type): f"data:{content_type};filename={filename};base64,{base64_encoded}" ) - except aiohttp.ClientError as e: + except aiohttp.ClientError: # log.error(f"Failed to download file: {file_url}, error: {e}") return None -async def send_message_raw(recipient_uuid, text=None, attachments=[]): +async def send_message_raw(recipient_uuid, text=None, attachments=None): """ Sends a message using the Signal REST API, ensuring attachment links are not included in the text body. @@ -84,6 +83,7 @@ async def send_message_raw(recipient_uuid, text=None, attachments=[]): } # Asynchronously download and encode all attachments + attachments = attachments or [] tasks = [ download_and_encode_base64(att["url"], att["filename"], att["content_type"]) for att in attachments @@ -182,12 +182,12 @@ def download_and_encode_base64_sync(file_url, filename, content_type): # Format according to Signal's expected structure return f"data:{content_type};filename={filename};base64,{base64_encoded}" - except requests.RequestException as e: + except requests.RequestException: # log.error(f"Failed to download file: {file_url}, error: {e}") return None -def send_message_raw_sync(recipient_uuid, text=None, attachments=[]): +def send_message_raw_sync(recipient_uuid, text=None, attachments=None): """ Sends a message using the Signal REST API, ensuring attachment links are not included in the text body. @@ -199,13 +199,16 @@ def send_message_raw_sync(recipient_uuid, text=None, attachments=[]): Returns: int | bool: Timestamp if successful, False otherwise. """ - url = "http://signal:8080/v2/send" + base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/") + url = f"{base}/v2/send" data = { "recipients": [recipient_uuid], "number": settings.SIGNAL_NUMBER, "base64_attachments": [], } + attachments = attachments or [] + # Convert attachments to Base64 for att in attachments: base64_data = download_and_encode_base64_sync( @@ -225,7 +228,7 @@ def send_message_raw_sync(recipient_uuid, text=None, attachments=[]): try: response = requests.post(url, json=data, timeout=10) response.raise_for_status() - except requests.RequestException as e: + except requests.RequestException: # log.error(f"Failed to send Signal message: {e}") return False diff --git a/core/clients/transport.py b/core/clients/transport.py new file mode 100644 index 0000000..c7ab7d9 --- /dev/null +++ b/core/clients/transport.py @@ -0,0 +1,417 @@ +import asyncio +import base64 +import io +import secrets +import time +from typing import Any + +import aiohttp +import orjson +import qrcode +from django.conf import settings +from django.core.cache import cache + +from core.clients import signalapi +from core.messaging import media_bridge +from core.util import logs + +log = logs.get_logger("transport") + +_RUNTIME_STATE_TTL = 60 * 60 * 24 +_RUNTIME_CLIENTS: dict[str, Any] = {} + + +def _service_key(service: str) -> str: + return str(service or "").strip().lower() + + +def _runtime_key(service: str) -> str: + return f"gia:service:runtime:{_service_key(service)}" + + +def _gateway_base(service: str) -> str: + key = f"{service.upper()}_HTTP_URL" + default = f"http://{service}:8080" + return str(getattr(settings, key, default)).rstrip("/") + + +def _as_qr_png(data: str) -> bytes: + image = qrcode.make(data) + stream = io.BytesIO() + image.save(stream, format="PNG") + return stream.getvalue() + + +def _parse_timestamp(data: Any): + if isinstance(data, dict): + ts = data.get("timestamp") + if ts: + return ts + return None + + +def register_runtime_client(service: str, client: Any): + """ + Register an in-process runtime client (UR process). + """ + _RUNTIME_CLIENTS[_service_key(service)] = client + + +def get_runtime_client(service: str): + return _RUNTIME_CLIENTS.get(_service_key(service)) + + +def get_runtime_state(service: str) -> dict[str, Any]: + return dict(cache.get(_runtime_key(service)) or {}) + + +def update_runtime_state(service: str, **updates): + """ + Persist runtime state to shared cache so web/UI process can read it. + """ + key = _runtime_key(service) + state = dict(cache.get(key) or {}) + state.update(updates) + state["updated_at"] = int(time.time()) + cache.set(key, state, timeout=_RUNTIME_STATE_TTL) + return state + + +def list_accounts(service: str): + """ + Return account identifiers for service UI list. + """ + service_key = _service_key(service) + if service_key == "signal": + import requests + + base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip("/") + try: + response = requests.get(f"{base}/v1/accounts", timeout=20) + if not response.ok: + return [] + payload = orjson.loads(response.text or "[]") + if isinstance(payload, list): + return payload + except Exception: + return [] + return [] + + state = get_runtime_state(service_key) + accounts = state.get("accounts") or [] + if isinstance(accounts, list): + return accounts + return [] + + +def get_service_warning(service: str) -> str: + service_key = _service_key(service) + if service_key == "signal": + return "" + + state = get_runtime_state(service_key) + warning = str(state.get("warning") or "").strip() + if warning: + return warning + + if not state.get("connected"): + return ( + f"{service_key.title()} runtime is not connected yet. " + "Start UR with the service enabled, open Services -> " + f"{service_key.title()} -> Add Account, then scan the QR from " + "WhatsApp Linked Devices." + ) + return "" + + +async def _gateway_json(method: str, url: str, payload=None): + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as session: + request = getattr(session, method.lower()) + async with request(url, json=payload) as response: + body = await response.read() + if not body: + return response.status, None + try: + return response.status, orjson.loads(body) + except Exception: + return response.status, None + + +async def _normalize_gateway_attachment(service: str, row: dict, session): + normalized = dict(row or {}) + content = normalized.get("content") + if isinstance(content, memoryview): + content = content.tobytes() + if isinstance(content, bytes): + blob_key = media_bridge.put_blob( + service=service, + content=content, + filename=normalized.get("filename") or "attachment.bin", + content_type=normalized.get("content_type") or "application/octet-stream", + ) + return { + "blob_key": blob_key, + "filename": normalized.get("filename") or "attachment.bin", + "content_type": normalized.get("content_type") + or "application/octet-stream", + "size": normalized.get("size") or len(content), + } + + if normalized.get("blob_key"): + return normalized + + source_url = normalized.get("url") + if source_url: + try: + async with session.get(source_url) as response: + if response.status == 200: + payload = await response.read() + blob_key = media_bridge.put_blob( + service=service, + content=payload, + filename=normalized.get("filename") + or source_url.rstrip("/").split("/")[-1] + or "attachment.bin", + content_type=normalized.get("content_type") + or response.headers.get( + "Content-Type", "application/octet-stream" + ), + ) + return { + "blob_key": blob_key, + "filename": normalized.get("filename") + or source_url.rstrip("/").split("/")[-1] + or "attachment.bin", + "content_type": normalized.get("content_type") + or response.headers.get( + "Content-Type", "application/octet-stream" + ), + "size": normalized.get("size") or len(payload), + } + except Exception: + log.warning("%s attachment fetch failed for %s", service, source_url) + return normalized + + +async def _gateway_send(service: str, recipient: str, text=None, attachments=None): + base = _gateway_base(service) + url = f"{base}/v1/send" + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as media_session: + normalized_attachments = await asyncio.gather( + *[ + _normalize_gateway_attachment(service, dict(att or {}), media_session) + for att in (attachments or []) + ] + ) + + data = { + "recipient": recipient, + "text": text or "", + "attachments": normalized_attachments, + } + status, payload = await _gateway_json("post", url, data) + if 200 <= status < 300: + ts = _parse_timestamp(payload) + return ts if ts else True + log.warning("%s gateway send failed (%s): %s", service, status, payload) + return False + + +async def _gateway_typing(service: str, recipient: str, started: bool): + base = _gateway_base(service) + action = "start" if started else "stop" + url = f"{base}/v1/typing/{action}" + payload = {"recipient": recipient} + status, _ = await _gateway_json("post", url, payload) + if 200 <= status < 300: + return True + return False + + +async def send_message_raw(service: str, recipient: str, text=None, attachments=None): + """ + Unified outbound send path used by models/views/UR. + """ + service_key = _service_key(service) + if service_key == "signal": + return await signalapi.send_message_raw(recipient, text, attachments or []) + + if service_key in {"whatsapp", "instagram"}: + runtime_client = get_runtime_client(service_key) + if runtime_client and hasattr(runtime_client, "send_message_raw"): + try: + runtime_result = await runtime_client.send_message_raw( + recipient, + text=text, + attachments=attachments or [], + ) + if runtime_result is not False and runtime_result is not None: + return runtime_result + except Exception as exc: + log.warning("%s runtime send failed: %s", service_key, exc) + return await _gateway_send( + service_key, + recipient, + text=text, + attachments=attachments or [], + ) + + if service_key == "xmpp": + raise NotImplementedError("Direct XMPP send is handled by the XMPP client.") + raise NotImplementedError(f"Unsupported service: {service}") + + +async def start_typing(service: str, recipient: str): + service_key = _service_key(service) + if service_key == "signal": + await signalapi.start_typing(recipient) + return True + + if service_key in {"whatsapp", "instagram"}: + runtime_client = get_runtime_client(service_key) + if runtime_client and hasattr(runtime_client, "start_typing"): + try: + result = await runtime_client.start_typing(recipient) + if result: + return True + except Exception as exc: + log.warning("%s runtime start_typing failed: %s", service_key, exc) + return await _gateway_typing(service_key, recipient, started=True) + return False + + +async def stop_typing(service: str, recipient: str): + service_key = _service_key(service) + if service_key == "signal": + await signalapi.stop_typing(recipient) + return True + + if service_key in {"whatsapp", "instagram"}: + runtime_client = get_runtime_client(service_key) + if runtime_client and hasattr(runtime_client, "stop_typing"): + try: + result = await runtime_client.stop_typing(recipient) + if result: + return True + except Exception as exc: + log.warning("%s runtime stop_typing failed: %s", service_key, exc) + return await _gateway_typing(service_key, recipient, started=False) + return False + + +async def fetch_attachment(service: str, attachment_ref: dict): + """ + Fetch attachment bytes from a source service or URL. + """ + service_key = _service_key(service) + if service_key == "signal": + attachment_id = attachment_ref.get("id") or attachment_ref.get("attachment_id") + if not attachment_id: + return None + return await signalapi.fetch_signal_attachment(attachment_id) + + runtime_client = get_runtime_client(service_key) + if runtime_client and hasattr(runtime_client, "fetch_attachment"): + try: + from_runtime = await runtime_client.fetch_attachment(attachment_ref) + if from_runtime: + return from_runtime + except Exception as exc: + log.warning("%s runtime attachment fetch failed: %s", service_key, exc) + + direct_url = attachment_ref.get("url") + blob_key = attachment_ref.get("blob_key") + if blob_key: + return media_bridge.get_blob(blob_key) + if direct_url: + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(direct_url) as response: + if response.status != 200: + return None + content = await response.read() + return { + "content": content, + "content_type": response.headers.get( + "Content-Type", + attachment_ref.get("content_type", "application/octet-stream"), + ), + "filename": attachment_ref.get("filename") + or direct_url.rstrip("/").split("/")[-1] + or "attachment.bin", + "size": len(content), + } + return None + + +def _qr_from_runtime_state(service: str) -> bytes | None: + state = get_runtime_state(service) + qr_payload = str(state.get("pair_qr") or "").strip() + if not qr_payload: + return None + if qr_payload.startswith("data:image/") and "," in qr_payload: + _, b64_data = qr_payload.split(",", 1) + try: + return base64.b64decode(b64_data) + except Exception: + return None + return _as_qr_png(qr_payload) + + +def get_link_qr(service: str, device_name: str): + """ + Returns PNG bytes for account-linking QR. + + - Signal: uses signal-cli REST endpoint. + - WhatsApp/Instagram: runtime QR from shared state when available. + Falls back to local pairing token QR in development. + """ + service_key = _service_key(service) + device = (device_name or "GIA Device").strip() + + if service_key == "signal": + import requests + + base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip("/") + response = requests.get( + f"{base}/v1/qrcodelink", + params={"device_name": device}, + timeout=20, + ) + response.raise_for_status() + return response.content + + if service_key in {"whatsapp", "instagram"}: + runtime_client = get_runtime_client(service_key) + if runtime_client and hasattr(runtime_client, "get_link_qr_png"): + try: + image_bytes = runtime_client.get_link_qr_png(device) + if image_bytes: + return image_bytes + except Exception: + pass + + cached = _qr_from_runtime_state(service_key) + if cached: + return cached + + token = secrets.token_urlsafe(24) + uri = f"gia://{service_key}/link?device={device}&token={token}" + update_runtime_state( + service_key, + pair_device=device, + pair_requested_at=int(time.time()), + warning=( + "Waiting for runtime pairing QR. " + "If this persists, check UR logs and Neonize session state." + ), + ) + return _as_qr_png(uri) + + raise NotImplementedError(f"Unsupported service for QR linking: {service}") + + +def image_bytes_to_base64(image_bytes: bytes) -> str: + return base64.b64encode(image_bytes).decode("utf-8") diff --git a/core/clients/whatsapp.py b/core/clients/whatsapp.py new file mode 100644 index 0000000..179500a --- /dev/null +++ b/core/clients/whatsapp.py @@ -0,0 +1,627 @@ +import asyncio +import re +import time + +import aiohttp +from asgiref.sync import sync_to_async +from django.conf import settings + +from core.clients import ClientBase, transport +from core.messaging import history, media_bridge +from core.models import PersonIdentifier + + +class WhatsAppClient(ClientBase): + """ + Async WhatsApp transport backed by Neonize. + + Design notes: + - Runs in UR process. + - Publishes runtime state to shared cache via transport. + - Degrades gracefully when Neonize/session is unavailable. + """ + + def __init__(self, ur, loop, service="whatsapp"): + super().__init__(ur, loop, service) + self._task = None + self._stopping = False + self._client = None + self._build_jid = None + self._connected = False + self._last_qr_payload = "" + self._accounts = [] + + self.enabled = bool( + str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower() + in {"1", "true", "yes", "on"} + ) + self.client_name = str( + getattr(settings, "WHATSAPP_CLIENT_NAME", "gia_whatsapp") + ).strip() or "gia_whatsapp" + self.database_url = str( + getattr(settings, "WHATSAPP_DATABASE_URL", "") + ).strip() + + transport.register_runtime_client(self.service, self) + self._publish_state( + connected=False, + warning=( + "WhatsApp runtime is disabled by settings." + if not self.enabled + else "" + ), + accounts=[], + ) + + def _publish_state(self, **updates): + state = transport.update_runtime_state(self.service, **updates) + accounts = state.get("accounts") + if isinstance(accounts, list): + self._accounts = accounts + + def start(self): + if not self.enabled: + self.log.info("whatsapp client disabled by settings") + return + if self._task is None: + self.log.info("whatsapp neonize client starting") + self._task = self.loop.create_task(self._run()) + + async def _run(self): + try: + from neonize.aioze.client import NewAClient + from neonize.aioze import events as wa_events + try: + from neonize.utils import build_jid as wa_build_jid + except Exception: + wa_build_jid = None + except Exception as exc: + self._publish_state( + connected=False, + warning=f"Neonize not available: {exc}", + accounts=[], + ) + self.log.warning("whatsapp neonize import failed: %s", exc) + return + + self._build_jid = wa_build_jid + self._client = self._build_client(NewAClient) + if self._client is None: + self._publish_state( + connected=False, + warning="Failed to initialize Neonize client.", + accounts=[], + ) + return + + self._register_event_handlers(wa_events) + + try: + await self._maybe_await(self._client.connect()) + except asyncio.CancelledError: + raise + except Exception as exc: + self._publish_state( + connected=False, + warning=f"WhatsApp connect failed: {exc}", + accounts=[], + ) + self.log.warning("whatsapp connect failed: %s", exc) + return + + # Keep task alive so state/callbacks remain active. + while not self._stopping: + await asyncio.sleep(1) + + def _build_client(self, cls): + candidates = [] + if self.database_url: + candidates.append((self.client_name, self.database_url)) + candidates.append((self.client_name,)) + for args in candidates: + try: + return cls(*args) + except TypeError: + continue + except Exception as exc: + self.log.warning("whatsapp client init failed for args %s: %s", args, exc) + try: + if self.database_url: + return cls(name=self.client_name, database=self.database_url) + return cls(name=self.client_name) + except Exception as exc: + self.log.warning("whatsapp client init failed: %s", exc) + return None + + def _register_event_handlers(self, wa_events): + connected_ev = getattr(wa_events, "ConnectedEv", None) + message_ev = getattr(wa_events, "MessageEv", None) + receipt_ev = getattr(wa_events, "ReceiptEv", None) + presence_ev = getattr(wa_events, "PresenceEv", None) + pair_ev = getattr(wa_events, "PairStatusEv", None) + + if connected_ev is not None: + + async def on_connected(client, event: connected_ev): + self._connected = True + account = await self._resolve_account_identifier() + self._publish_state( + connected=True, + warning="", + accounts=[account] if account else [self.client_name], + ) + + self._client.event(on_connected) + + if message_ev is not None: + + async def on_message(client, event: message_ev): + await self._handle_message_event(event) + + self._client.event(on_message) + + if receipt_ev is not None: + + async def on_receipt(client, event: receipt_ev): + await self._handle_receipt_event(event) + + self._client.event(on_receipt) + + if presence_ev is not None: + + async def on_presence(client, event: presence_ev): + await self._handle_presence_event(event) + + self._client.event(on_presence) + + if pair_ev is not None: + + async def on_pair_status(client, event: pair_ev): + qr_payload = self._extract_pair_qr(event) + if qr_payload: + self._last_qr_payload = qr_payload + self._publish_state( + pair_qr=qr_payload, + warning="Scan QR to pair WhatsApp account.", + ) + + self._client.event(on_pair_status) + + async def _maybe_await(self, value): + if asyncio.iscoroutine(value): + return await value + return value + + async def _resolve_account_identifier(self): + if self._client is None: + return "" + if not hasattr(self._client, "get_me"): + return self.client_name + try: + me = await self._maybe_await(self._client.get_me()) + except Exception: + return self.client_name + # Support both dict-like and object-like payloads. + for path in ( + ("JID", "User"), + ("jid",), + ("user",), + ("ID",), + ): + value = self._pluck(me, *path) + if value: + return str(value) + return self.client_name + + def _pluck(self, obj, *path): + current = obj + for key in path: + if current is None: + return None + if isinstance(current, dict): + current = current.get(key) + continue + if hasattr(current, key): + current = getattr(current, key) + continue + return None + return current + + def _normalize_timestamp(self, raw_value): + if raw_value is None: + return int(time.time() * 1000) + try: + value = int(raw_value) + except Exception: + return int(time.time() * 1000) + # WhatsApp libs often emit seconds. Promote to ms. + if value < 10**12: + return value * 1000 + return value + + def _normalize_identifier_candidates(self, *values): + out = set() + for value in values: + raw = str(value or "").strip() + if not raw: + continue + out.add(raw) + if "@" in raw: + out.add(raw.split("@", 1)[0]) + digits = re.sub(r"[^0-9]", "", raw) + if digits: + out.add(digits) + if not digits.startswith("+"): + out.add(f"+{digits}") + return out + + def _is_media_message(self, message_obj): + media_fields = ( + "imageMessage", + "videoMessage", + "audioMessage", + "documentMessage", + "stickerMessage", + "image_message", + "video_message", + "audio_message", + "document_message", + "sticker_message", + ) + for field in media_fields: + value = self._pluck(message_obj, field) + if value: + return True + return False + + async def _download_event_media(self, event): + if not self._client: + return [] + msg_obj = self._pluck(event, "message") + if msg_obj is None or not self._is_media_message(msg_obj): + return [] + if not hasattr(self._client, "download_any"): + return [] + + try: + payload = await self._maybe_await(self._client.download_any(msg_obj)) + except Exception as exc: + self.log.warning("whatsapp media download failed: %s", exc) + return [] + + if isinstance(payload, memoryview): + payload = payload.tobytes() + if not isinstance(payload, (bytes, bytearray)): + return [] + + filename = ( + self._pluck(msg_obj, "documentMessage", "fileName") + or self._pluck(msg_obj, "document_message", "file_name") + or f"wa-{int(time.time())}.bin" + ) + content_type = ( + self._pluck(msg_obj, "documentMessage", "mimetype") + or self._pluck(msg_obj, "document_message", "mimetype") + or self._pluck(msg_obj, "imageMessage", "mimetype") + or self._pluck(msg_obj, "image_message", "mimetype") + or "application/octet-stream" + ) + blob_key = media_bridge.put_blob( + service="whatsapp", + content=bytes(payload), + filename=filename, + content_type=content_type, + ) + if not blob_key: + return [] + return [ + { + "blob_key": blob_key, + "filename": filename, + "content_type": content_type, + "size": len(payload), + } + ] + + async def _handle_message_event(self, event): + msg_obj = self._pluck(event, "message") + text = ( + self._pluck(msg_obj, "conversation") + or self._pluck(msg_obj, "extendedTextMessage", "text") + or self._pluck(msg_obj, "extended_text_message", "text") + or "" + ) + + sender = ( + self._pluck(event, "info", "message_source", "sender") + or self._pluck(event, "info", "messageSource", "sender") + or "" + ) + chat = ( + self._pluck(event, "info", "message_source", "chat") + or self._pluck(event, "info", "messageSource", "chat") + or "" + ) + raw_ts = ( + self._pluck(event, "info", "timestamp") + or self._pluck(event, "info", "message_timestamp") + or self._pluck(event, "timestamp") + ) + ts = self._normalize_timestamp(raw_ts) + + identifier_values = self._normalize_identifier_candidates(sender, chat) + if not identifier_values: + return + + identifiers = await sync_to_async(list)( + PersonIdentifier.objects.filter( + service="whatsapp", + identifier__in=list(identifier_values), + ) + ) + if not identifiers: + return + + attachments = await self._download_event_media(event) + xmpp_attachments = [] + if attachments: + fetched = await asyncio.gather( + *[transport.fetch_attachment(self.service, att) for att in attachments] + ) + xmpp_attachments = [row for row in fetched if row] + + payload = { + "sender": str(sender or ""), + "chat": str(chat or ""), + "raw_event": str(type(event).__name__), + } + + for identifier in identifiers: + session = await history.get_chat_session(identifier.user, identifier) + await history.store_message( + session=session, + sender=str(sender or chat or ""), + text=text, + ts=ts, + outgoing=False, + ) + await self.ur.xmpp.client.send_from_external( + identifier.user, + identifier, + text, + is_outgoing_message=False, + attachments=xmpp_attachments, + ) + await self.ur.message_received( + self.service, + identifier=identifier, + text=text, + ts=ts, + payload=payload, + ) + + async def _handle_receipt_event(self, event): + sender = ( + self._pluck(event, "info", "message_source", "sender") + or self._pluck(event, "info", "messageSource", "sender") + or "" + ) + chat = ( + self._pluck(event, "info", "message_source", "chat") + or self._pluck(event, "info", "messageSource", "chat") + or "" + ) + timestamps = [] + raw_ids = self._pluck(event, "message_ids") or [] + if isinstance(raw_ids, list): + for item in raw_ids: + try: + value = int(item) + timestamps.append(value * 1000 if value < 10**12 else value) + except Exception: + continue + read_ts = self._normalize_timestamp(self._pluck(event, "timestamp") or int(time.time() * 1000)) + + for candidate in self._normalize_identifier_candidates(sender, chat): + await self.ur.message_read( + self.service, + identifier=candidate, + message_timestamps=timestamps, + read_ts=read_ts, + read_by=sender or chat, + payload={"event": "receipt", "sender": str(sender), "chat": str(chat)}, + ) + + async def _handle_presence_event(self, event): + sender = ( + self._pluck(event, "message_source", "sender") + or self._pluck(event, "info", "message_source", "sender") + or "" + ) + chat = ( + self._pluck(event, "message_source", "chat") + or self._pluck(event, "info", "message_source", "chat") + or "" + ) + presence = str(self._pluck(event, "presence") or "").strip().lower() + + for candidate in self._normalize_identifier_candidates(sender, chat): + if presence in {"composing", "typing", "recording"}: + await self.ur.started_typing( + self.service, + identifier=candidate, + payload={"presence": presence, "sender": str(sender), "chat": str(chat)}, + ) + elif presence: + await self.ur.stopped_typing( + self.service, + identifier=candidate, + payload={"presence": presence, "sender": str(sender), "chat": str(chat)}, + ) + + def _extract_pair_qr(self, event): + for path in ( + ("qr",), + ("qr_code",), + ("code",), + ("pair_code",), + ("pairCode",), + ("url",), + ): + value = self._pluck(event, *path) + if value: + return str(value) + return "" + + def _to_jid(self, recipient): + raw = str(recipient or "").strip() + if not raw: + return "" + if self._build_jid is not None: + try: + return self._build_jid(raw) + except Exception: + pass + if "@" in raw: + return raw + digits = re.sub(r"[^0-9]", "", raw) + if digits: + return f"{digits}@s.whatsapp.net" + return raw + + async def _fetch_attachment_payload(self, attachment): + blob_key = (attachment or {}).get("blob_key") + if blob_key: + row = media_bridge.get_blob(blob_key) + if row: + return row + + content = (attachment or {}).get("content") + if isinstance(content, memoryview): + content = content.tobytes() + if isinstance(content, bytes): + return { + "content": content, + "filename": (attachment or {}).get("filename") or "attachment.bin", + "content_type": (attachment or {}).get("content_type") + or "application/octet-stream", + "size": len(content), + } + + url = (attachment or {}).get("url") + if url: + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url) as response: + if response.status != 200: + return None + payload = await response.read() + return { + "content": payload, + "filename": (attachment or {}).get("filename") + or url.rstrip("/").split("/")[-1] + or "attachment.bin", + "content_type": (attachment or {}).get("content_type") + or response.headers.get( + "Content-Type", "application/octet-stream" + ), + "size": len(payload), + } + return None + + async def send_message_raw(self, recipient, text=None, attachments=None): + if not self._client: + return False + jid = self._to_jid(recipient) + if not jid: + return False + + sent_any = False + for attachment in attachments or []: + payload = await self._fetch_attachment_payload(attachment) + if not payload: + continue + mime = str(payload.get("content_type") or "application/octet-stream").lower() + data = payload.get("content") or b"" + filename = payload.get("filename") or "attachment.bin" + + try: + if mime.startswith("image/") and hasattr(self._client, "send_image"): + await self._maybe_await(self._client.send_image(jid, data, caption="")) + elif mime.startswith("video/") and hasattr(self._client, "send_video"): + await self._maybe_await(self._client.send_video(jid, data, caption="")) + elif mime.startswith("audio/") and hasattr(self._client, "send_audio"): + await self._maybe_await(self._client.send_audio(jid, data)) + elif hasattr(self._client, "send_document"): + await self._maybe_await( + self._client.send_document( + jid, + data, + filename=filename, + mimetype=mime, + caption="", + ) + ) + sent_any = True + except Exception as exc: + self.log.warning("whatsapp attachment send failed: %s", exc) + + if text: + try: + await self._maybe_await(self._client.send_message(jid, text)) + sent_any = True + except TypeError: + await self._maybe_await(self._client.send_message(jid, message=text)) + sent_any = True + except Exception as exc: + self.log.warning("whatsapp text send failed: %s", exc) + return False + + return int(time.time() * 1000) if sent_any else False + + async def start_typing(self, identifier): + if not self._client: + return False + jid = self._to_jid(identifier) + if not jid: + return False + for method_name in ("send_chat_presence", "set_chat_presence"): + if hasattr(self._client, method_name): + method = getattr(self._client, method_name) + try: + await self._maybe_await(method(jid, "composing")) + return True + except Exception: + continue + return False + + async def stop_typing(self, identifier): + if not self._client: + return False + jid = self._to_jid(identifier) + if not jid: + return False + for method_name in ("send_chat_presence", "set_chat_presence"): + if hasattr(self._client, method_name): + method = getattr(self._client, method_name) + try: + await self._maybe_await(method(jid, "paused")) + return True + except Exception: + continue + return False + + async def fetch_attachment(self, attachment_ref): + blob_key = (attachment_ref or {}).get("blob_key") + if blob_key: + return media_bridge.get_blob(blob_key) + return None + + def get_link_qr_png(self, device_name): + _ = (device_name or "").strip() + if not self._last_qr_payload: + return None + try: + return transport._as_qr_png(self._last_qr_payload) + except Exception: + return None diff --git a/core/clients/xmpp.py b/core/clients/xmpp.py index cd4a791..0e6fc1c 100644 --- a/core/clients/xmpp.py +++ b/core/clients/xmpp.py @@ -10,12 +10,13 @@ from slixmpp.stanza import Message from slixmpp.xmlstream import register_stanza_plugin from slixmpp.xmlstream.stanzabase import ET -from core.clients import ClientBase, signalapi +from core.clients import ClientBase from core.messaging import ai, history, replies, utils from core.models import ( ChatSession, Manipulation, PatternMitigationAutoSettings, + PatternMitigationCorrection, PatternMitigationGame, PatternMitigationPlan, PatternMitigationRule, @@ -91,21 +92,13 @@ class XMPPComponent(ComponentXMPP): sender_bare_jid = sender_parts[0] # Always present: user@domain sender_username, sender_domain = sender_bare_jid.split("@", 1) - sender_resource = ( - sender_parts[1] if len(sender_parts) > 1 else None - ) # Extract resource if present - # Extract recipient JID (should match component JID format) recipient_jid = str(msg["to"]) if "@" in recipient_jid: - recipient_username, recipient_domain = recipient_jid.split("@", 1) + recipient_username = recipient_jid.split("@", 1)[0] else: recipient_username = recipient_jid - recipient_domain = recipient_jid - - # Extract message body - body = msg["body"] if msg["body"] else "[No Body]" # Parse recipient_name and recipient_service (e.g., "mark|signal") if "|" in recipient_username: person_name, service = recipient_username.split("|") @@ -134,9 +127,15 @@ class XMPPComponent(ComponentXMPP): return None def _get_workspace_conversation(self, user, person): + primary_identifier = ( + PersonIdentifier.objects.filter(user=user, person=person) + .order_by("service") + .first() + ) + platform_type = primary_identifier.service if primary_identifier else "signal" conversation, _ = WorkspaceConversation.objects.get_or_create( user=user, - platform_type="signal", + platform_type=platform_type, title=f"{person.name} Workspace", defaults={"platform_thread_id": str(person.id)}, ) @@ -186,6 +185,10 @@ class XMPPComponent(ComponentXMPP): ".mitigation rule-del | | " ".mitigation game-add <person>|<title>|<instructions> | " ".mitigation game-del <person>|<title> | " + ".mitigation correction-add <person>|<title>|<clarification> | " + ".mitigation correction-del <person>|<title> | " + ".mitigation fundamentals-set <person>|<item1;item2;...> | " + ".mitigation plan-set <person>|<draft|active|archived>|<auto|guided> | " ".mitigation auto <person>|on|off | " ".mitigation auto-status <person>" ) @@ -214,7 +217,9 @@ class XMPPComponent(ComponentXMPP): if command.startswith(".mitigation show "): person_name = command.replace(".mitigation show ", "", 1).strip().title() person = await sync_to_async( - lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first() + lambda: Person.objects.filter( + user=sender_user, name__iexact=person_name + ).first() )() if not person: sym("Unknown person.") @@ -231,9 +236,15 @@ class XMPPComponent(ComponentXMPP): if len(parts) < 3: sym("Usage: .mitigation rule-add <person>|<title>|<content>") return True - person_name, title, content = parts[0].title(), parts[1], "|".join(parts[2:]) + person_name, title, content = ( + parts[0].title(), + parts[1], + "|".join(parts[2:]), + ) person = await sync_to_async( - lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first() + lambda: Person.objects.filter( + user=sender_user, name__iexact=person_name + ).first() )() if not person: sym("Unknown person.") @@ -257,7 +268,9 @@ class XMPPComponent(ComponentXMPP): return True person_name, title = parts[0].title(), "|".join(parts[1:]) person = await sync_to_async( - lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first() + lambda: Person.objects.filter( + user=sender_user, name__iexact=person_name + ).first() )() if not person: sym("Unknown person.") @@ -279,9 +292,15 @@ class XMPPComponent(ComponentXMPP): if len(parts) < 3: sym("Usage: .mitigation game-add <person>|<title>|<instructions>") return True - person_name, title, content = parts[0].title(), parts[1], "|".join(parts[2:]) + person_name, title, content = ( + parts[0].title(), + parts[1], + "|".join(parts[2:]), + ) person = await sync_to_async( - lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first() + lambda: Person.objects.filter( + user=sender_user, name__iexact=person_name + ).first() )() if not person: sym("Unknown person.") @@ -305,7 +324,9 @@ class XMPPComponent(ComponentXMPP): return True person_name, title = parts[0].title(), "|".join(parts[1:]) person = await sync_to_async( - lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first() + lambda: Person.objects.filter( + user=sender_user, name__iexact=person_name + ).first() )() if not person: sym("Unknown person.") @@ -321,6 +342,128 @@ class XMPPComponent(ComponentXMPP): sym("Game deleted." if deleted else "Game not found.") return True + if command.startswith(".mitigation correction-add "): + payload = command.replace(".mitigation correction-add ", "", 1) + parts = parse_parts(payload) + if len(parts) < 3: + sym( + "Usage: .mitigation correction-add <person>|<title>|<clarification>" + ) + return True + person_name, title, clarification = ( + parts[0].title(), + parts[1], + "|".join(parts[2:]), + ) + person = await sync_to_async( + lambda: Person.objects.filter( + user=sender_user, name__iexact=person_name + ).first() + )() + if not person: + sym("Unknown person.") + return True + plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) + await sync_to_async(PatternMitigationCorrection.objects.create)( + user=sender_user, + plan=plan, + title=title[:255], + clarification=clarification, + source_phrase="", + perspective="second_person", + share_target="both", + language_style="adapted", + enabled=True, + ) + sym("Correction added.") + return True + + if command.startswith(".mitigation correction-del "): + payload = command.replace(".mitigation correction-del ", "", 1) + parts = parse_parts(payload) + if len(parts) < 2: + sym("Usage: .mitigation correction-del <person>|<title>") + return True + person_name, title = parts[0].title(), "|".join(parts[1:]) + person = await sync_to_async( + lambda: Person.objects.filter( + user=sender_user, name__iexact=person_name + ).first() + )() + if not person: + sym("Unknown person.") + return True + plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) + deleted, _ = await sync_to_async( + lambda: PatternMitigationCorrection.objects.filter( + user=sender_user, + plan=plan, + title__iexact=title, + ).delete() + )() + sym("Correction deleted." if deleted else "Correction not found.") + return True + + if command.startswith(".mitigation fundamentals-set "): + payload = command.replace(".mitigation fundamentals-set ", "", 1) + parts = parse_parts(payload) + if len(parts) < 2: + sym("Usage: .mitigation fundamentals-set <person>|<item1;item2;...>") + return True + person_name, values = parts[0].title(), "|".join(parts[1:]) + person = await sync_to_async( + lambda: Person.objects.filter( + user=sender_user, name__iexact=person_name + ).first() + )() + if not person: + sym("Unknown person.") + return True + plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) + items = [item.strip() for item in values.split(";") if item.strip()] + plan.fundamental_items = items + await sync_to_async(plan.save)( + update_fields=["fundamental_items", "updated_at"] + ) + sym(f"Fundamentals updated ({len(items)}).") + return True + + if command.startswith(".mitigation plan-set "): + payload = command.replace(".mitigation plan-set ", "", 1) + parts = parse_parts(payload) + if len(parts) < 3: + sym( + "Usage: .mitigation plan-set <person>|<draft|active|archived>|<auto|guided>" + ) + return True + person_name, status_value, mode_value = ( + parts[0].title(), + parts[1].lower(), + parts[2].lower(), + ) + person = await sync_to_async( + lambda: Person.objects.filter( + user=sender_user, name__iexact=person_name + ).first() + )() + if not person: + sym("Unknown person.") + return True + plan = await sync_to_async(self._get_or_create_plan)(sender_user, person) + valid_status = {key for key, _ in PatternMitigationPlan.STATUS_CHOICES} + valid_modes = { + key for key, _ in PatternMitigationPlan.CREATION_MODE_CHOICES + } + if status_value in valid_status: + plan.status = status_value + if mode_value in valid_modes: + plan.creation_mode = mode_value + await sync_to_async(plan.save)( + update_fields=["status", "creation_mode", "updated_at"] + ) + sym(f"Plan updated: status={plan.status}, mode={plan.creation_mode}") + return True + if command.startswith(".mitigation auto "): payload = command.replace(".mitigation auto ", "", 1) parts = parse_parts(payload) @@ -329,31 +472,47 @@ class XMPPComponent(ComponentXMPP): return True person_name, state = parts[0].title(), parts[1].lower() person = await sync_to_async( - lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first() + lambda: Person.objects.filter( + user=sender_user, name__iexact=person_name + ).first() )() if not person: sym("Unknown person.") return True - conversation = await sync_to_async(self._get_workspace_conversation)(sender_user, person) - auto_obj, _ = await sync_to_async(PatternMitigationAutoSettings.objects.get_or_create)( + conversation = await sync_to_async(self._get_workspace_conversation)( + sender_user, person + ) + auto_obj, _ = await sync_to_async( + PatternMitigationAutoSettings.objects.get_or_create + )( user=sender_user, conversation=conversation, ) auto_obj.enabled = state in {"on", "true", "1", "yes"} await sync_to_async(auto_obj.save)(update_fields=["enabled", "updated_at"]) - sym(f"Automation {'enabled' if auto_obj.enabled else 'disabled'} for {person.name}.") + sym( + f"Automation {'enabled' if auto_obj.enabled else 'disabled'} for {person.name}." + ) return True if command.startswith(".mitigation auto-status "): - person_name = command.replace(".mitigation auto-status ", "", 1).strip().title() + person_name = ( + command.replace(".mitigation auto-status ", "", 1).strip().title() + ) person = await sync_to_async( - lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first() + lambda: Person.objects.filter( + user=sender_user, name__iexact=person_name + ).first() )() if not person: sym("Unknown person.") return True - conversation = await sync_to_async(self._get_workspace_conversation)(sender_user, person) - auto_obj, _ = await sync_to_async(PatternMitigationAutoSettings.objects.get_or_create)( + conversation = await sync_to_async(self._get_workspace_conversation)( + sender_user, person + ) + auto_obj, _ = await sync_to_async( + PatternMitigationAutoSettings.objects.get_or_create + )( user=sender_user, conversation=conversation, ) @@ -383,7 +542,7 @@ class XMPPComponent(ComponentXMPP): """ self.log.info(f"Chat state: Active from {msg['from']}.") - identifier = self.get_identifier(msg) + self.get_identifier(msg) def on_chatstate_composing(self, msg): """ @@ -392,6 +551,13 @@ class XMPPComponent(ComponentXMPP): self.log.info(f"Chat state: Composing from {msg['from']}.") identifier = self.get_identifier(msg) + if identifier: + asyncio.create_task( + self.ur.started_typing( + "xmpp", + identifier=identifier, + ) + ) def on_chatstate_paused(self, msg): """ @@ -400,6 +566,13 @@ class XMPPComponent(ComponentXMPP): self.log.info(f"Chat state: Paused from {msg['from']}.") identifier = self.get_identifier(msg) + if identifier: + asyncio.create_task( + self.ur.stopped_typing( + "xmpp", + identifier=identifier, + ) + ) def on_chatstate_inactive(self, msg): """ @@ -407,7 +580,7 @@ class XMPPComponent(ComponentXMPP): """ self.log.info(f"Chat state: Inactive from {msg['from']}.") - identifier = self.get_identifier(msg) + self.get_identifier(msg) def on_chatstate_gone(self, msg): """ @@ -415,7 +588,7 @@ class XMPPComponent(ComponentXMPP): """ self.log.info(f"Chat state: Gone from {msg['from']}.") - identifier = self.get_identifier(msg) + self.get_identifier(msg) def on_presence_available(self, pres): """ @@ -621,7 +794,9 @@ class XMPPComponent(ComponentXMPP): Process incoming XMPP messages. """ - sym = lambda x: msg.reply(f"[>] {x}").send() + def sym(value): + msg.reply(f"[>] {value}").send() + # self.log.info(f"Received message: {msg}") # Extract sender JID (full format: user@domain/resource) @@ -710,7 +885,7 @@ class XMPPComponent(ComponentXMPP): # Construct contact list response contact_names = [person.name for person in persons] - response_text = f"Contacts: " + ", ".join(contact_names) + response_text = "Contacts: " + ", ".join(contact_names) sym(response_text) elif body == ".help": sym("Commands: .contacts, .whoami, .mitigation help") @@ -785,12 +960,11 @@ class XMPPComponent(ComponentXMPP): ) self.log.info(f"MANIP11 {manipulations}") if not manipulations: - tss = await signalapi.send_message_raw( - identifier.identifier, + await identifier.send( body, attachments, ) - self.log.info(f"Message sent unaltered") + self.log.info("Message sent unaltered") return manip = manipulations.first() @@ -810,12 +984,11 @@ class XMPPComponent(ComponentXMPP): text=result, ts=int(now().timestamp() * 1000), ) - tss = await signalapi.send_message_raw( - identifier.identifier, + await identifier.send( result, attachments, ) - self.log.info(f"Message sent with modifications") + self.log.info("Message sent with modifications") async def request_upload_slots(self, recipient_jid, attachments): """Requests upload slots for multiple attachments concurrently.""" @@ -898,7 +1071,7 @@ class XMPPComponent(ComponentXMPP): # Step 2: Request upload slots concurrently valid_uploads = await self.request_upload_slots(recipient_jid, attachments) - self.log.info(f"Got upload slots") + self.log.info("Got upload slots") if not valid_uploads: self.log.warning("No valid upload slots obtained.") # return diff --git a/core/db/sql.py b/core/db/sql.py index 3cf487e..882e940 100644 --- a/core/db/sql.py +++ b/core/db/sql.py @@ -1,3 +1,5 @@ +import asyncio + import aiomysql from core.schemas import mc_s @@ -41,7 +43,10 @@ async def create_index(): for name, schema in schemas.items(): schema_types = ", ".join([f"{k} {v}" for k, v in schema.items()]) - create_query = f"create table if not exists {name}({schema_types}) engine='columnar'" + create_query = ( + f"create table if not exists {name}({schema_types}) " + "engine='columnar'" + ) log.info(f"Schema types {create_query}") await cur.execute(create_query) # SQLi except aiomysql.Error as e: diff --git a/core/forms.py b/core/forms.py index 2ff4d4d..9fd0446 100644 --- a/core/forms.py +++ b/core/forms.py @@ -80,8 +80,14 @@ class PersonIdentifierForm(RestrictedFormMixin, forms.ModelForm): model = PersonIdentifier fields = ("identifier", "service") help_texts = { - "identifier": "The unique identifier (e.g., username or phone number) for the person.", - "service": "The platform associated with this identifier (e.g., Signal, Instagram).", + "identifier": ( + "The unique identifier (e.g., username or phone number) " + "for the person." + ), + "service": ( + "The platform associated with this identifier " + "(e.g., Signal, WhatsApp, Instagram, XMPP)." + ), } @@ -153,16 +159,27 @@ class PersonaForm(RestrictedFormMixin, forms.ModelForm): help_texts = { "alias": "The preferred name or identity for this persona.", "mbti": "Select the Myers-Briggs Type Indicator (MBTI) personality type.", - "mbti_identity": "Identity assertiveness: -1 (Turbulent) to +1 (Assertive).", + "mbti_identity": ( + "Identity assertiveness: -1 (Turbulent) to +1 (Assertive)." + ), "inner_story": "A brief background or philosophy that shapes this persona.", "core_values": "The guiding principles and values of this persona.", - "communication_style": "How this persona prefers to communicate (e.g., direct, formal, casual).", + "communication_style": ( + "How this persona prefers to communicate " + "(e.g., direct, formal, casual)." + ), "flirting_style": "How this persona expresses attraction.", "humor_style": "Preferred style of humor (e.g., dry, dark, sarcastic).", "likes": "Topics and things this persona enjoys discussing.", "dislikes": "Topics and behaviors this persona prefers to avoid.", - "tone": "The general tone this persona prefers (e.g., formal, witty, detached).", - "response_tactics": "How this persona handles manipulation (e.g., gaslighting, guilt-tripping).", + "tone": ( + "The general tone this persona prefers " + "(e.g., formal, witty, detached)." + ), + "response_tactics": ( + "How this persona handles manipulation " + "(e.g., gaslighting, guilt-tripping)." + ), "persuasion_tactics": "The methods this persona uses to convince others.", "boundaries": "What this persona will not tolerate in conversations.", "trust": "Initial trust level (0-100) given in interactions.", @@ -182,7 +199,9 @@ class ManipulationForm(RestrictedFormMixin, forms.ModelForm): "persona": "The persona used for this manipulation.", "enabled": "Whether this manipulation is enabled.", "mode": "Mode of operation.", - "filter_enabled": "Whether incoming messages will be filtered using the persona.", + "filter_enabled": ( + "Whether incoming messages will be filtered using the persona." + ), } @@ -199,12 +218,27 @@ class SessionForm(RestrictedFormMixin, forms.ModelForm): class MessageForm(RestrictedFormMixin, forms.ModelForm): class Meta: model = Message - fields = ("session", "sender_uuid", "text", "custom_author") + fields = ( + "session", + "sender_uuid", + "text", + "custom_author", + "delivered_ts", + "read_ts", + "read_source_service", + "read_by_identifier", + "receipt_payload", + ) help_texts = { "session": "Chat session this message was sent in.", "sender_uuid": "UUID of the sender.", "text": "Content of the message.", "custom_author": "For detecting USER and BOT messages.", + "delivered_ts": "Delivery timestamp in unix milliseconds, if known.", + "read_ts": "Read timestamp in unix milliseconds, if known.", + "read_source_service": "Service that reported this read receipt.", + "read_by_identifier": "Identifier that read this message.", + "receipt_payload": "Raw normalized receipt metadata.", } diff --git a/core/lib/deferred.py b/core/lib/deferred.py index 270c18f..7ba3b51 100644 --- a/core/lib/deferred.py +++ b/core/lib/deferred.py @@ -4,12 +4,12 @@ from typing import Annotated, Optional from uuid import UUID from asgiref.sync import sync_to_async -from django.conf import settings +from django.utils import timezone as dj_timezone from pydantic import BaseModel, ValidationError -from core.clients import signal, signalapi -from core.lib.prompts.functions import delete_messages -from core.models import Message, PersonIdentifier, QueuedMessage, User +from core.clients import serviceapi +from core.messaging import natural +from core.models import Message, PersonIdentifier, QueuedMessage from core.util import logs log = logs.get_logger("deferred") @@ -34,12 +34,23 @@ class DeferredRequest(BaseModel): async def send_message(db_obj): - recipient_uuid = db_obj.session.identifier.identifier + identifier = db_obj.session.identifier + recipient_uuid = identifier.identifier + service = identifier.service text = db_obj.text - send = lambda x: signalapi.send_message_raw(recipient_uuid, x) # returns ts - start_t = lambda: signalapi.start_typing(recipient_uuid) - stop_t = lambda: signalapi.stop_typing(recipient_uuid) + async def send(value): + return await serviceapi.send_message_raw( + service, + recipient_uuid, + value, + ) # returns ts + + async def start_t(): + return await serviceapi.start_typing(service, recipient_uuid) + + async def stop_t(): + return await serviceapi.stop_typing(service, recipient_uuid) tss = await natural.natural_send_message( text, @@ -52,13 +63,17 @@ async def send_message(db_obj): result = [x for x in tss if x] # all trueish ts if result: # if at least one message was sent ts1 = result.pop() # pick a time - log.info(f"signal message create {text}") + if isinstance(ts1, bool): + ts1 = int(dj_timezone.now().timestamp() * 1000) + log.info("Stored outbound message for %s: %s", service, text) await sync_to_async(Message.objects.create)( user=db_obj.session.user, session=db_obj.session, custom_author="BOT", text=text, ts=ts1, # use that time in db + delivered_ts=ts1, + read_source_service=service, ) @@ -86,12 +101,7 @@ async def process_deferred(data: dict, **kwargs): log.info(f"Didn't get message from {message_id}") return - if message.session.identifier.service == "signal": - await send_message(message) - - else: - log.warning(f"Protocol not supported: {message.session.identifier.service}") - return + await send_message(message) elif method == "xmpp": # send xmpp message xmpp = kwargs.get("xmpp") service = validated_data.service @@ -107,12 +117,16 @@ async def process_deferred(data: dict, **kwargs): # attachments = [] # Asynchronously fetch all attachments - tasks = [signalapi.fetch_signal_attachment(att["id"]) for att in attachments] + tasks = [serviceapi.fetch_attachment(service, att) for att in attachments] fetched_attachments = await asyncio.gather(*tasks) for fetched, att in zip(fetched_attachments, attachments): if not fetched: - log.warning(f"Failed to fetch attachment {att['id']} from Signal.") + log.warning( + "Failed to fetch attachment %s from %s.", + att.get("id"), + service, + ) continue # Attach fetched file to XMPP @@ -129,7 +143,9 @@ async def process_deferred(data: dict, **kwargs): user = identifier.user log.info( - f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP." + "Sending %s attachments from %s to XMPP.", + len(xmpp_attachments), + service, ) await xmpp.send_from_external( user, diff --git a/core/management/commands/scheduling.py b/core/management/commands/scheduling.py index 481bc88..1e5246e 100644 --- a/core/management/commands/scheduling.py +++ b/core/management/commands/scheduling.py @@ -1,7 +1,6 @@ import asyncio from apscheduler.schedulers.asyncio import AsyncIOScheduler -from asgiref.sync import sync_to_async from django.core.management.base import BaseCommand from core.util import logs diff --git a/core/management/commands/ur.py b/core/management/commands/ur.py index c54fec6..f08c964 100644 --- a/core/management/commands/ur.py +++ b/core/management/commands/ur.py @@ -1,6 +1,5 @@ import asyncio -from django.conf import settings from django.core.management.base import BaseCommand from core.modules.router import UnifiedRouter diff --git a/core/messaging/ai.py b/core/messaging/ai.py index bd421f3..7ed3d5d 100644 --- a/core/messaging/ai.py +++ b/core/messaging/ai.py @@ -1,6 +1,6 @@ -from openai import AsyncOpenAI, OpenAI +from openai import AsyncOpenAI -from core.models import AI, ChatSession, Manipulation, Message, Person +from core.models import AI async def run_prompt( diff --git a/core/messaging/analysis.py b/core/messaging/analysis.py index 24ba7d4..495562d 100644 --- a/core/messaging/analysis.py +++ b/core/messaging/analysis.py @@ -1,14 +1,7 @@ -import asyncio -import json -import random - -from asgiref.sync import sync_to_async from django.utils import timezone from openai import AsyncOpenAI -from core.lib.prompts import bases -from core.models import AI, ChatSession, Manipulation, Message, Person -from core.util import logs +from core.models import AI, Manipulation, Person def generate_prompt(msg: dict, person: Person, manip: Manipulation, chat_history: str): @@ -62,7 +55,7 @@ async def run_context_prompt( ): cast = {"api_key": ai.api_key} if ai.base_url is not None: - cast["api_key"] = ai.base_url + cast["base_url"] = ai.base_url client = AsyncOpenAI(**cast) response = await client.chat.completions.create( model=ai.model, diff --git a/core/messaging/history.py b/core/messaging/history.py index e6f93ed..186a936 100644 --- a/core/messaging/history.py +++ b/core/messaging/history.py @@ -15,7 +15,11 @@ log = logs.get_logger("history") DEFAULT_PROMPT_HISTORY_MAX_MESSAGES = getattr( settings, "PROMPT_HISTORY_MAX_MESSAGES", 120 ) -DEFAULT_PROMPT_HISTORY_MAX_CHARS = getattr(settings, "PROMPT_HISTORY_MAX_CHARS", 24000) +DEFAULT_PROMPT_HISTORY_MAX_CHARS = getattr( + settings, + "PROMPT_HISTORY_MAX_CHARS", + 24000, +) DEFAULT_PROMPT_HISTORY_MIN_MESSAGES = getattr( settings, "PROMPT_HISTORY_MIN_MESSAGES", 24 ) @@ -40,7 +44,8 @@ def _build_recent_history(messages, max_chars): total_chars = 0 # Recency-first packing, then reorder to chronological output later. for msg in reversed(messages): - line = f"[{msg.ts}] <{msg.custom_author if msg.custom_author else msg.session.identifier.person.name}> {msg.text}" + author = msg.custom_author or msg.session.identifier.person.name + line = f"[{msg.ts}] <{author}> {msg.text}" line_len = len(line) + 1 # Keep at least one line even if it alone exceeds max_chars. if selected and (total_chars + line_len) > max_chars: @@ -147,6 +152,7 @@ async def store_message(session, sender, text, ts, outgoing=False): sender_uuid=sender, text=text, ts=ts, + delivered_ts=ts, custom_author="USER" if outgoing else None, ) @@ -161,6 +167,7 @@ async def store_own_message(session, text, ts, manip=None, queue=False): "custom_author": "BOT", "text": text, "ts": ts, + "delivered_ts": ts, } if queue: msg_object = QueuedMessage @@ -177,3 +184,62 @@ async def store_own_message(session, text, ts, manip=None, queue=False): async def delete_queryset(queryset): await sync_to_async(queryset.delete, thread_sensitive=True)() + + +async def apply_read_receipts( + user, + identifier, + message_timestamps, + read_ts=None, + source_service="signal", + read_by_identifier="", + payload=None, +): + """ + Persist delivery/read metadata for one identifier's messages. + """ + ts_values = [] + for item in message_timestamps or []: + try: + ts_values.append(int(item)) + except Exception: + continue + if not ts_values: + return 0 + + try: + read_at = int(read_ts) if read_ts else None + except Exception: + read_at = None + + rows = await sync_to_async(list)( + Message.objects.filter( + user=user, + session__identifier=identifier, + ts__in=ts_values, + ).select_related("session") + ) + updated = 0 + for message in rows: + dirty = [] + if message.delivered_ts is None: + message.delivered_ts = read_at or message.ts + dirty.append("delivered_ts") + if read_at and (message.read_ts is None or read_at > message.read_ts): + message.read_ts = read_at + dirty.append("read_ts") + if source_service and message.read_source_service != source_service: + message.read_source_service = source_service + dirty.append("read_source_service") + if read_by_identifier and message.read_by_identifier != read_by_identifier: + message.read_by_identifier = read_by_identifier + dirty.append("read_by_identifier") + if payload: + receipt_data = dict(message.receipt_payload or {}) + receipt_data[str(source_service)] = payload + message.receipt_payload = receipt_data + dirty.append("receipt_payload") + if dirty: + await sync_to_async(message.save)(update_fields=dirty) + updated += 1 + return updated diff --git a/core/messaging/media_bridge.py b/core/messaging/media_bridge.py new file mode 100644 index 0000000..6fa184a --- /dev/null +++ b/core/messaging/media_bridge.py @@ -0,0 +1,48 @@ +import base64 +import hashlib +import time + +from django.core.cache import cache + + +DEFAULT_BLOB_TTL_SECONDS = 60 * 20 + + +def _blob_cache_key(service, digest): + return f"gia:media:{service}:{digest}" + + +def put_blob(service, content, filename, content_type, ttl=DEFAULT_BLOB_TTL_SECONDS): + if not content: + return None + digest = hashlib.sha1(content).hexdigest() + key = _blob_cache_key(service, digest) + cache.set( + key, + { + "filename": filename or "attachment.bin", + "content_type": content_type or "application/octet-stream", + "content_b64": base64.b64encode(content).decode("utf-8"), + "size": len(content), + "stored_at": int(time.time()), + }, + timeout=ttl, + ) + return key + + +def get_blob(key): + row = cache.get(key) + if not row: + return None + try: + content = base64.b64decode(row.get("content_b64", "")) + except Exception: + return None + return { + "content": content, + "filename": row.get("filename") or "attachment.bin", + "content_type": row.get("content_type") or "application/octet-stream", + "size": row.get("size") or len(content), + "stored_at": row.get("stored_at"), + } diff --git a/core/messaging/natural.py b/core/messaging/natural.py index 4f2ac5a..bd0d778 100644 --- a/core/messaging/natural.py +++ b/core/messaging/natural.py @@ -1,5 +1,4 @@ import asyncio -import random async def natural_send_message( @@ -11,7 +10,8 @@ async def natural_send_message( Args: chat_session: The active chat session. ts: Timestamp of the message. - c: The context or object with `.send()`, `.start_typing()`, and `.stop_typing()` methods. + c: The context or object with `.send()`, `.start_typing()`, + and `.stop_typing()` methods. text: A string containing multiple messages separated by double newlines (`\n\n`). Behavior: @@ -34,7 +34,7 @@ async def natural_send_message( length_factor = len(message) / 25 # ~50 chars ≈ +1s processing # ~25 chars ≈ +1s processing - natural_delay = min(base_delay + length_factor, 10) # Cap at 5s max + natural_delay = min(base_delay + length_factor, 10) # Cap at 10s max # Decide when to start thinking *before* typing if not skip_thinking: diff --git a/core/messaging/replies.py b/core/messaging/replies.py index 97929d3..2afdda2 100644 --- a/core/messaging/replies.py +++ b/core/messaging/replies.py @@ -1,12 +1,6 @@ -import asyncio -import json -import random - -from asgiref.sync import sync_to_async from django.utils import timezone -from core.lib.prompts import bases -from core.models import AI, ChatSession, Manipulation, Message, Person +from core.models import Manipulation, Person from core.util import logs log = logs.get_logger("replies") diff --git a/core/messaging/utils.py b/core/messaging/utils.py index 134d823..6306c71 100644 --- a/core/messaging/utils.py +++ b/core/messaging/utils.py @@ -2,13 +2,26 @@ from asgiref.sync import sync_to_async from django.utils import timezone -def messages_to_string(messages: list): +def messages_to_string(messages: list, author_rewrites: dict | None = None): """ Converts message objects to a formatted string, showing custom_author if set. """ + author_rewrites = { + str(key).strip().upper(): str(value) + for key, value in (author_rewrites or {}).items() + } + + def _author_label(message): + author = ( + message.custom_author + if message.custom_author + else message.session.identifier.person.name + ) + mapped = author_rewrites.get(str(author).strip().upper()) + return mapped if mapped else author + message_texts = [ - f"[{msg.ts}] <{msg.custom_author if msg.custom_author else msg.session.identifier.person.name}> {msg.text}" - for msg in messages + f"[{msg.ts}] <{_author_label(msg)}> {msg.text}" for msg in messages ] return "\n".join(message_texts) diff --git a/core/migrations/0016_airequest_airesult_workspaceconversation_and_more.py b/core/migrations/0016_airequest_airesult_workspaceconversation_and_more.py index 0e3cdff..c3f3a0c 100644 --- a/core/migrations/0016_airequest_airesult_workspaceconversation_and_more.py +++ b/core/migrations/0016_airequest_airesult_workspaceconversation_and_more.py @@ -1,7 +1,8 @@ # Generated by Django 5.2.11 on 2026-02-14 22:52 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models diff --git a/core/migrations/0017_remove_airesult_risk_flags_and_more.py b/core/migrations/0017_remove_airesult_risk_flags_and_more.py index f25b28f..37e582c 100644 --- a/core/migrations/0017_remove_airesult_risk_flags_and_more.py +++ b/core/migrations/0017_remove_airesult_risk_flags_and_more.py @@ -1,11 +1,13 @@ # Generated by Django 5.2.11 on 2026-02-15 00:14 -import core.models -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models +import core.models + class Migration(migrations.Migration): diff --git a/core/migrations/0018_patternmitigationplan_patternmitigationmessage_and_more.py b/core/migrations/0018_patternmitigationplan_patternmitigationmessage_and_more.py index 6280132..49d777d 100644 --- a/core/migrations/0018_patternmitigationplan_patternmitigationmessage_and_more.py +++ b/core/migrations/0018_patternmitigationplan_patternmitigationmessage_and_more.py @@ -1,7 +1,8 @@ # Generated by Django 5.2.11 on 2026-02-15 00:58 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models diff --git a/core/migrations/0019_patternmitigationcorrection.py b/core/migrations/0019_patternmitigationcorrection.py index 8ca5057..3f399b1 100644 --- a/core/migrations/0019_patternmitigationcorrection.py +++ b/core/migrations/0019_patternmitigationcorrection.py @@ -1,7 +1,8 @@ # Generated by Django 5.2.11 on 2026-02-15 01:13 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models diff --git a/core/migrations/0022_patternmitigationautosettings.py b/core/migrations/0022_patternmitigationautosettings.py index 6807f5d..298568c 100644 --- a/core/migrations/0022_patternmitigationautosettings.py +++ b/core/migrations/0022_patternmitigationautosettings.py @@ -1,7 +1,8 @@ # Generated by Django 5.2.11 on 2026-02-15 02:38 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models diff --git a/core/migrations/0023_message_delivered_ts_message_read_by_identifier_and_more.py b/core/migrations/0023_message_delivered_ts_message_read_by_identifier_and_more.py new file mode 100644 index 0000000..9385721 --- /dev/null +++ b/core/migrations/0023_message_delivered_ts_message_read_by_identifier_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 5.2.11 on 2026-02-15 15:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0022_patternmitigationautosettings'), + ] + + operations = [ + migrations.AddField( + model_name='message', + name='delivered_ts', + field=models.BigIntegerField(blank=True, help_text='Delivery timestamp (unix ms) when known.', null=True), + ), + migrations.AddField( + model_name='message', + name='read_by_identifier', + field=models.CharField(blank=True, help_text='Identifier that read this message (service-native value).', max_length=255, null=True), + ), + migrations.AddField( + model_name='message', + name='read_source_service', + field=models.CharField(blank=True, choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram')], help_text='Service that reported the read receipt.', max_length=255, null=True), + ), + migrations.AddField( + model_name='message', + name='read_ts', + field=models.BigIntegerField(blank=True, help_text='Read timestamp (unix ms) when known.', null=True), + ), + migrations.AddField( + model_name='message', + name='receipt_payload', + field=models.JSONField(blank=True, default=dict, help_text='Raw normalized delivery/read receipt metadata.'), + ), + migrations.AlterField( + model_name='messageevent', + name='source_system', + field=models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram'), ('workspace', 'Workspace'), ('ai', 'AI')], default='signal', help_text='System that produced this event record.', max_length=32), + ), + migrations.AlterField( + model_name='personidentifier', + name='service', + field=models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram')], max_length=255), + ), + migrations.AlterField( + model_name='workspaceconversation', + name='platform_type', + field=models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram')], default='signal', help_text='Primary transport for this conversation (reuses SERVICE_CHOICES).', max_length=255), + ), + ] diff --git a/core/migrations/0024_workspacemetricsnapshot.py b/core/migrations/0024_workspacemetricsnapshot.py new file mode 100644 index 0000000..bee2d96 --- /dev/null +++ b/core/migrations/0024_workspacemetricsnapshot.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.7 on 2026-02-15 16:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0023_message_delivered_ts_message_read_by_identifier_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='WorkspaceMetricSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('computed_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='When this snapshot was persisted.')), + ('source_event_ts', models.BigIntegerField(blank=True, help_text='Latest message timestamp used during this metric computation.', null=True)), + ('stability_state', models.CharField(choices=[('calibrating', 'Calibrating'), ('stable', 'Stable'), ('watch', 'Watch'), ('fragile', 'Fragile')], default='calibrating', help_text='Stability state at computation time.', max_length=32)), + ('stability_score', models.FloatField(blank=True, help_text='Stability score (0-100).', null=True)), + ('stability_confidence', models.FloatField(default=0.0, help_text='Confidence in stability score (0.0-1.0).')), + ('stability_sample_messages', models.PositiveIntegerField(default=0, help_text='How many messages were in the sampled window.')), + ('stability_sample_days', models.PositiveIntegerField(default=0, help_text='How many days were in the sampled window.')), + ('commitment_inbound_score', models.FloatField(blank=True, help_text='Commitment estimate counterpart -> user (0-100).', null=True)), + ('commitment_outbound_score', models.FloatField(blank=True, help_text='Commitment estimate user -> counterpart (0-100).', null=True)), + ('commitment_confidence', models.FloatField(default=0.0, help_text='Confidence in commitment scores (0.0-1.0).')), + ('inbound_messages', models.PositiveIntegerField(default=0, help_text='Inbound message count in the sampled window.')), + ('outbound_messages', models.PositiveIntegerField(default=0, help_text='Outbound message count in the sampled window.')), + ('reciprocity_score', models.FloatField(blank=True, help_text='Balance component used for stability.', null=True)), + ('continuity_score', models.FloatField(blank=True, help_text='Continuity component used for stability.', null=True)), + ('response_score', models.FloatField(blank=True, help_text='Response-time component used for stability.', null=True)), + ('volatility_score', models.FloatField(blank=True, help_text='Volatility component used for stability.', null=True)), + ('inbound_response_score', models.FloatField(blank=True, help_text='Inbound response-lag score used for commitment.', null=True)), + ('outbound_response_score', models.FloatField(blank=True, help_text='Outbound response-lag score used for commitment.', null=True)), + ('balance_inbound_score', models.FloatField(blank=True, help_text='Inbound balance score used for commitment.', null=True)), + ('balance_outbound_score', models.FloatField(blank=True, help_text='Outbound balance score used for commitment.', null=True)), + ('conversation', models.ForeignKey(help_text='Workspace conversation this metric snapshot belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='metric_snapshots', to='core.workspaceconversation')), + ], + options={ + 'ordering': ('-computed_at',), + 'indexes': [models.Index(fields=['conversation', 'computed_at'], name='core_worksp_convers_4ee793_idx')], + }, + ), + ] diff --git a/core/models.py b/core/models.py index fe84462..afd9a14 100644 --- a/core/models.py +++ b/core/models.py @@ -1,19 +1,19 @@ -import logging import hashlib +import logging import uuid -from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser from django.db import models -from core.clients import signalapi +from core.clients import transport from core.lib.notify import raw_sendmsg logger = logging.getLogger(__name__) SERVICE_CHOICES = ( ("signal", "Signal"), + ("whatsapp", "WhatsApp"), ("xmpp", "XMPP"), ("instagram", "Instagram"), ) @@ -61,7 +61,7 @@ def _attribute_display_id(kind, *parts): n_letters //= 26 digits = int(digest[8:16], 16) % 10000 - return f"{''.join(letters)}{digits:04d}" + return f"{''.join(letters)}{str(digits).zfill(4)}" def get_default_workspace_user_pk(): @@ -157,20 +157,16 @@ class PersonIdentifier(models.Model): def __str__(self): return f"{self.person} ({self.service})" - async def send(self, text, attachments=[]): + async def send(self, text, attachments=None): """ Send this contact a text. """ - if self.service == "signal": - ts = await signalapi.send_message_raw( - self.identifier, - text, - attachments, - ) - print("SENT") - return ts - else: - raise NotImplementedError(f"Service not implemented: {self.service}") + return await transport.send_message_raw( + self.service, + self.identifier, + text=text, + attachments=attachments or [], + ) class ChatSession(models.Model): @@ -214,6 +210,34 @@ class Message(models.Model): text = models.TextField(blank=True, null=True) custom_author = models.CharField(max_length=255, blank=True, null=True) + delivered_ts = models.BigIntegerField( + null=True, + blank=True, + help_text="Delivery timestamp (unix ms) when known.", + ) + read_ts = models.BigIntegerField( + null=True, + blank=True, + help_text="Read timestamp (unix ms) when known.", + ) + read_source_service = models.CharField( + max_length=255, + choices=SERVICE_CHOICES, + null=True, + blank=True, + help_text="Service that reported the read receipt.", + ) + read_by_identifier = models.CharField( + max_length=255, + blank=True, + null=True, + help_text="Identifier that read this message (service-native value).", + ) + receipt_payload = models.JSONField( + default=dict, + blank=True, + help_text="Raw normalized delivery/read receipt metadata.", + ) class Meta: ordering = ["ts"] @@ -471,14 +495,130 @@ class WorkspaceConversation(models.Model): return self.title or f"{self.platform_type}:{self.id}" +class WorkspaceMetricSnapshot(models.Model): + """ + Historical snapshots of workspace metrics for trend visualisation. + """ + + conversation = models.ForeignKey( + WorkspaceConversation, + on_delete=models.CASCADE, + related_name="metric_snapshots", + help_text="Workspace conversation this metric snapshot belongs to.", + ) + computed_at = models.DateTimeField( + auto_now_add=True, + db_index=True, + help_text="When this snapshot was persisted.", + ) + source_event_ts = models.BigIntegerField( + null=True, + blank=True, + help_text="Latest message timestamp used during this metric computation.", + ) + stability_state = models.CharField( + max_length=32, + choices=WorkspaceConversation.StabilityState.choices, + default=WorkspaceConversation.StabilityState.CALIBRATING, + help_text="Stability state at computation time.", + ) + stability_score = models.FloatField( + null=True, + blank=True, + help_text="Stability score (0-100).", + ) + stability_confidence = models.FloatField( + default=0.0, + help_text="Confidence in stability score (0.0-1.0).", + ) + stability_sample_messages = models.PositiveIntegerField( + default=0, + help_text="How many messages were in the sampled window.", + ) + stability_sample_days = models.PositiveIntegerField( + default=0, + help_text="How many days were in the sampled window.", + ) + commitment_inbound_score = models.FloatField( + null=True, + blank=True, + help_text="Commitment estimate counterpart -> user (0-100).", + ) + commitment_outbound_score = models.FloatField( + null=True, + blank=True, + help_text="Commitment estimate user -> counterpart (0-100).", + ) + commitment_confidence = models.FloatField( + default=0.0, + help_text="Confidence in commitment scores (0.0-1.0).", + ) + inbound_messages = models.PositiveIntegerField( + default=0, + help_text="Inbound message count in the sampled window.", + ) + outbound_messages = models.PositiveIntegerField( + default=0, + help_text="Outbound message count in the sampled window.", + ) + reciprocity_score = models.FloatField( + null=True, + blank=True, + help_text="Balance component used for stability.", + ) + continuity_score = models.FloatField( + null=True, + blank=True, + help_text="Continuity component used for stability.", + ) + response_score = models.FloatField( + null=True, + blank=True, + help_text="Response-time component used for stability.", + ) + volatility_score = models.FloatField( + null=True, + blank=True, + help_text="Volatility component used for stability.", + ) + inbound_response_score = models.FloatField( + null=True, + blank=True, + help_text="Inbound response-lag score used for commitment.", + ) + outbound_response_score = models.FloatField( + null=True, + blank=True, + help_text="Outbound response-lag score used for commitment.", + ) + balance_inbound_score = models.FloatField( + null=True, + blank=True, + help_text="Inbound balance score used for commitment.", + ) + balance_outbound_score = models.FloatField( + null=True, + blank=True, + help_text="Outbound balance score used for commitment.", + ) + + class Meta: + ordering = ("-computed_at",) + indexes = [ + models.Index(fields=["conversation", "computed_at"]), + ] + + def __str__(self): + return f"Metrics {self.conversation_id} @ {self.computed_at.isoformat()}" + + class MessageEvent(models.Model): """ Normalized message event used by workspace timeline and AI selection windows. """ SOURCE_SYSTEM_CHOICES = ( - ("signal", "Signal"), - ("xmpp", "XMPP"), + *SERVICE_CHOICES, ("workspace", "Workspace"), ("ai", "AI"), ) @@ -499,7 +639,10 @@ class MessageEvent(models.Model): on_delete=models.CASCADE, related_name="workspace_message_events", default=get_default_workspace_user_pk, - help_text="Owner of this message event row (required for restricted CRUD filtering).", + help_text=( + "Owner of this message event row " + "(required for restricted CRUD filtering)." + ), ) conversation = models.ForeignKey( WorkspaceConversation, @@ -679,7 +822,9 @@ class AIResult(models.Model): on_delete=models.CASCADE, related_name="workspace_ai_results", default=get_default_workspace_user_pk, - help_text="Owner of this AI result row (required for restricted CRUD filtering).", + help_text=( + "Owner of this AI result row " "(required for restricted CRUD filtering)." + ), ) ai_request = models.OneToOneField( AIRequest, @@ -702,7 +847,8 @@ class AIResult(models.Model): blank=True, help_text=( "Structured positive/neutral/risk signals inferred for this run. " - "Example item: {'label':'repair_attempt','valence':'positive','message_event_ids':[...]}." + "Example item: {'label':'repair_attempt','valence':'positive'," + "'message_event_ids':[...]}." ), ) memory_proposals = models.JSONField( @@ -1089,7 +1235,8 @@ class PatternMitigationCorrection(models.Model): default="", help_text=( "Joint clarification text intended to reduce interpretation drift. " - "Example: 'When you say \"you ignore me\", I hear fear of disconnection, not blame.'" + 'Example: \'When you say "you ignore me", I hear fear of ' + "disconnection, not blame.'" ), ) source_phrase = models.TextField( @@ -1097,7 +1244,8 @@ class PatternMitigationCorrection(models.Model): default="", help_text=( "Situation/message fragment this correction responds to. " - "Example: 'she says: \"you never listen\"' or 'you say: \"you are dismissing me\"'." + "Example: 'she says: \"you never listen\"' or " + "'you say: \"you are dismissing me\"'." ), ) perspective = models.CharField( @@ -1106,14 +1254,18 @@ class PatternMitigationCorrection(models.Model): default="third_person", help_text=( "Narrative perspective used when framing this correction. " - "Examples: third person ('she says'), second person ('you say'), first person ('I say')." + "Examples: third person ('she says'), second person ('you say'), " + "first person ('I say')." ), ) share_target = models.CharField( max_length=16, choices=SHARE_TARGET_CHOICES, default="both", - help_text="Who this insight is intended to be shared with. Example: self, other, or both.", + help_text=( + "Who this insight is intended to be shared with. " + "Example: self, other, or both." + ), ) language_style = models.CharField( max_length=16, @@ -1121,7 +1273,8 @@ class PatternMitigationCorrection(models.Model): default="adapted", help_text=( "Whether to keep wording identical or adapt it per recipient. " - "Example: same text for both parties, or softened/adapted wording for recipient." + "Example: same text for both parties, or softened/adapted wording " + "for recipient." ), ) enabled = models.BooleanField( diff --git a/core/modules/mixed_protocol.py b/core/modules/mixed_protocol.py new file mode 100644 index 0000000..a797d3e --- /dev/null +++ b/core/modules/mixed_protocol.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(slots=True) +class UnifiedEvent: + """ + Normalized event envelope shared across protocol adapters. + """ + + service: str + event_type: str + identifier: str = "" + text: str = "" + ts: int | None = None + message_timestamps: list[int] = field(default_factory=list) + attachments: list[dict[str, Any]] = field(default_factory=list) + payload: dict[str, Any] = field(default_factory=dict) + + +def normalize_gateway_event(service: str, payload: dict[str, Any]) -> UnifiedEvent: + event_type = str(payload.get("type") or "").strip().lower() + message_timestamps = [] + raw_timestamps = payload.get("message_timestamps") or payload.get("timestamps") or [] + if isinstance(raw_timestamps, list): + for item in raw_timestamps: + try: + message_timestamps.append(int(item)) + except Exception: + continue + elif raw_timestamps: + try: + message_timestamps = [int(raw_timestamps)] + except Exception: + message_timestamps = [] + + ts = payload.get("ts") or payload.get("timestamp") + try: + ts = int(ts) if ts is not None else None + except Exception: + ts = None + + return UnifiedEvent( + service=service, + event_type=event_type, + identifier=str( + payload.get("identifier") or payload.get("source") or payload.get("from") or "" + ).strip(), + text=str(payload.get("text") or ""), + ts=ts, + message_timestamps=message_timestamps, + attachments=list(payload.get("attachments") or []), + payload=dict(payload or {}), + ) diff --git a/core/modules/router.py b/core/modules/router.py index 4b266d3..dcda649 100644 --- a/core/modules/router.py +++ b/core/modules/router.py @@ -1,5 +1,12 @@ +from asgiref.sync import sync_to_async + +from core.clients import transport +from core.clients.instagram import InstagramClient from core.clients.signal import SignalClient +from core.clients.whatsapp import WhatsAppClient from core.clients.xmpp import XMPPClient +from core.messaging import history +from core.models import PersonIdentifier from core.util import logs @@ -16,11 +23,15 @@ class UnifiedRouter(object): self.xmpp = XMPPClient(self, loop, "xmpp") self.signal = SignalClient(self, loop, "signal") + self.whatsapp = WhatsAppClient(self, loop, "whatsapp") + self.instagram = InstagramClient(self, loop, "instagram") def _start(self): print("UR _start") self.xmpp.start() self.signal.start() + self.whatsapp.start() + self.instagram.start() def run(self): try: @@ -37,14 +48,66 @@ class UnifiedRouter(object): async def message_received(self, protocol, *args, **kwargs): self.log.info(f"Message received ({protocol}) {args} {kwargs}") + async def _resolve_identifier_objects(self, protocol, identifier): + if isinstance(identifier, PersonIdentifier): + return [identifier] + value = str(identifier or "").strip() + if not value: + return [] + return await sync_to_async(list)( + PersonIdentifier.objects.filter( + identifier=value, + service=protocol, + ) + ) + async def message_read(self, protocol, *args, **kwargs): self.log.info(f"Message read ({protocol}) {args} {kwargs}") + identifier = kwargs.get("identifier") + timestamps = kwargs.get("message_timestamps") or [] + read_ts = kwargs.get("read_ts") + payload = kwargs.get("payload") or {} + read_by = kwargs.get("read_by") or "" + + identifiers = await self._resolve_identifier_objects(protocol, identifier) + for row in identifiers: + await history.apply_read_receipts( + user=row.user, + identifier=row, + message_timestamps=timestamps, + read_ts=read_ts, + source_service=protocol, + read_by_identifier=read_by or row.identifier, + payload=payload, + ) async def started_typing(self, protocol, *args, **kwargs): self.log.info(f"Started typing ({protocol}) {args} {kwargs}") + identifier = kwargs.get("identifier") + identifiers = await self._resolve_identifier_objects(protocol, identifier) + for src in identifiers: + targets = await sync_to_async(list)( + PersonIdentifier.objects.filter( + user=src.user, + person=src.person, + ).exclude(service=protocol) + ) + for target in targets: + await transport.start_typing(target.service, target.identifier) async def stopped_typing(self, protocol, *args, **kwargs): self.log.info(f"Stopped typing ({protocol}) {args} {kwargs}") + identifier = kwargs.get("identifier") + identifiers = await self._resolve_identifier_objects(protocol, identifier) + for src in identifiers: + targets = await sync_to_async(list)( + PersonIdentifier.objects.filter( + user=src.user, + person=src.person, + ).exclude(service=protocol) + ) + for target in targets: + await transport.stop_typing(target.service, target.identifier) async def reacted(self, protocol, *args, **kwargs): self.log.info(f"Reacted ({protocol}) {args} {kwargs}") diff --git a/core/templates/base.html b/core/templates/base.html index 4cb1137..d91376a 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -295,7 +295,10 @@ <a class="navbar-item" href="{% url 'signal' %}"> Signal </a> - <a class="navbar-item" href="#"> + <a class="navbar-item" href="{% url 'whatsapp' %}"> + WhatsApp + </a> + <a class="navbar-item" href="{% url 'instagram' %}"> Instagram </a> </div> @@ -308,6 +311,20 @@ <div class="navbar-end"> {% if user.is_authenticated %} + <div class="navbar-item has-dropdown is-hoverable"> + <a + class="navbar-link" + hx-get="{% url 'compose_contacts_dropdown' %}" + hx-target="#nav-compose-contacts" + hx-trigger="click once" + hx-swap="innerHTML"> + <span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span> + <span style="margin-left: 0.35rem;">Message</span> + </a> + <div class="navbar-dropdown" id="nav-compose-contacts"> + <a class="navbar-item is-disabled">Load contacts</a> + </div> + </div> <a class="navbar-item" href="{% url 'ai_workspace' %}"> AI </a> diff --git a/core/templates/pages/ai-workspace-insight-detail.html b/core/templates/pages/ai-workspace-insight-detail.html new file mode 100644 index 0000000..a1af885 --- /dev/null +++ b/core/templates/pages/ai-workspace-insight-detail.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} + <div class="columns is-multiline"> + <div class="column is-12"> + <nav class="breadcrumb is-small" aria-label="breadcrumbs"> + <ul> + <li><a href="{{ workspace_url }}">AI Workspace</a></li> + <li><a href="{{ graphs_url }}">Insight Graphs</a></li> + <li class="is-active"><a aria-current="page">{{ metric.title }}</a></li> + </ul> + </nav> + </div> + <div class="column is-12"> + <h1 class="title is-4" style="margin-bottom: 0.35rem;">{{ metric.title }}: {{ person.name }}</h1> + <p class="is-size-7 has-text-grey">Conversation {{ workspace_conversation.id }}</p> + </div> + <div class="column is-5"> + <div class="box"> + <p class="heading">{{ metric_group.title }}</p> + <p class="title is-5" style="margin-bottom: 0.5rem;">{{ metric.title }}</p> + <p><strong>Current Value:</strong> {{ metric_value|default:"-" }}</p> + <p style="margin-top: 0.65rem;"><strong>How It Is Calculated</strong></p> + <p>{{ metric.calculation }}</p> + <p style="margin-top: 0.65rem;"><strong>Psychological Interpretation</strong></p> + <p>{{ metric.psychology }}</p> + {% if metric_psychology_hint %} + <article class="message is-info is-light" style="margin-top: 0.75rem;"> + <div class="message-body"> + {{ metric_psychology_hint }} + </div> + </article> + {% endif %} + </div> + <div class="buttons"> + <a class="button is-light" href="{{ graphs_url }}"> + <span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span> + <span>All Graphs</span> + </a> + <a class="button is-light" href="{{ help_url }}"> + <span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span> + <span>Scoring Help</span> + </a> + </div> + </div> + <div class="column is-7"> + <div class="box"> + <p class="heading">History</p> + <div style="height: 360px;"> + <canvas id="metric-detail-chart"></canvas> + </div> + {% if not graph_points %} + <p class="is-size-7 has-text-grey" style="margin-top: 0.65rem;"> + No historical points yet for this metric. + </p> + {% endif %} + </div> + </div> + </div> + + {{ graph_points|json_script:"metric-detail-points" }} + <script src="{% static 'js/chart.js' %}"></script> + <script> + (function() { + var node = document.getElementById("metric-detail-points"); + if (!node) { + return; + } + var points = JSON.parse(node.textContent || "[]"); + var labels = points.map(function(row) { + var dt = new Date(row.x); + return dt.toLocaleString(); + }); + var values = points.map(function(row) { + return row.y; + }); + var ctx = document.getElementById("metric-detail-chart"); + if (!ctx) { + return; + } + new Chart(ctx.getContext("2d"), { + type: "line", + data: { + labels: labels, + datasets: [ + { + label: "{{ metric.title|escapejs }}", + data: values, + borderColor: "#3273dc", + backgroundColor: "rgba(50,115,220,0.12)", + borderWidth: 2, + pointRadius: 2, + tension: 0.28, + spanGaps: true + } + ] + }, + options: { + maintainAspectRatio: false, + plugins: { + legend: { + display: true + } + } + } + }); + })(); + </script> +{% endblock %} diff --git a/core/templates/pages/ai-workspace-insight-graphs.html b/core/templates/pages/ai-workspace-insight-graphs.html new file mode 100644 index 0000000..cabd040 --- /dev/null +++ b/core/templates/pages/ai-workspace-insight-graphs.html @@ -0,0 +1,115 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} + <div class="columns is-multiline"> + <div class="column is-12"> + <nav class="breadcrumb is-small" aria-label="breadcrumbs"> + <ul> + <li><a href="{{ workspace_url }}">AI Workspace</a></li> + <li class="is-active"><a aria-current="page">Insight Graphs</a></li> + </ul> + </nav> + </div> + <div class="column is-12"> + <h1 class="title is-4" style="margin-bottom: 0.35rem;">Insight Graphs: {{ person.name }}</h1> + <p class="is-size-7 has-text-grey"> + Historical metrics for workspace {{ workspace_conversation.id }} + </p> + <div class="buttons are-small" style="margin-top: 0.6rem;"> + <a class="button is-light" href="{{ help_url }}"> + <span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span> + <span>Scoring Help</span> + </a> + </div> + </div> + + {% for graph in graph_cards %} + <div class="column is-6"> + <div class="box"> + <div class="is-flex is-justify-content-space-between is-align-items-center" style="margin-bottom: 0.45rem;"> + <div> + <p class="heading">{{ graph.group_title }}</p> + <p class="title is-6" style="margin-bottom: 0.2rem;">{{ graph.title }}</p> + <p class="is-size-7 has-text-grey">{{ graph.count }} point{{ graph.count|pluralize }}</p> + </div> + <div class="buttons are-small" style="margin: 0;"> + <a class="button is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=graph.slug %}"> + Detail + </a> + <a class="button is-light" href="{{ help_url }}#group-{{ graph.group }}"> + How It Works + </a> + </div> + </div> + <div style="height: 250px;"> + <canvas id="graph-{{ graph.slug }}"></canvas> + </div> + </div> + </div> + {% endfor %} + </div> + + {{ graph_cards|json_script:"insight-graph-cards" }} + <script src="{% static 'js/chart.js' %}"></script> + <script> + (function() { + var node = document.getElementById("insight-graph-cards"); + if (!node) { + return; + } + var graphs = JSON.parse(node.textContent || "[]"); + var palette = ["#3273dc", "#23d160", "#ffdd57", "#ff3860", "#7957d5", "#00d1b2"]; + + graphs.forEach(function(graph, idx) { + var canvas = document.getElementById("graph-" + graph.slug); + if (!canvas) { + return; + } + var labels = (graph.points || []).map(function(row) { + return new Date(row.x).toLocaleString(); + }); + var values = (graph.points || []).map(function(row) { + return row.y; + }); + var color = palette[idx % palette.length]; + var yScale = {}; + if (graph.y_min !== null && graph.y_min !== undefined) { + yScale.min = graph.y_min; + } + if (graph.y_max !== null && graph.y_max !== undefined) { + yScale.max = graph.y_max; + } + new Chart(canvas.getContext("2d"), { + type: "line", + data: { + labels: labels, + datasets: [ + { + label: graph.title, + data: values, + borderColor: color, + backgroundColor: color + "33", + borderWidth: 2, + pointRadius: 2, + tension: 0.24, + spanGaps: true + } + ] + }, + options: { + maintainAspectRatio: false, + scales: { + y: yScale + }, + plugins: { + legend: { + display: false + } + } + } + }); + }); + })(); + </script> +{% endblock %} diff --git a/core/templates/pages/ai-workspace-insight-help.html b/core/templates/pages/ai-workspace-insight-help.html new file mode 100644 index 0000000..5248cc4 --- /dev/null +++ b/core/templates/pages/ai-workspace-insight-help.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block content %} + <div class="columns is-multiline"> + <div class="column is-12"> + <nav class="breadcrumb is-small" aria-label="breadcrumbs"> + <ul> + <li><a href="{{ workspace_url }}">AI Workspace</a></li> + <li><a href="{{ graphs_url }}">Insight Graphs</a></li> + <li class="is-active"><a aria-current="page">Scoring Help</a></li> + </ul> + </nav> + </div> + <div class="column is-12"> + <h1 class="title is-4" style="margin-bottom: 0.35rem;">Scoring Help: {{ person.name }}</h1> + <p class="is-size-7 has-text-grey"> + Combined explanation for each metric collection group and what it can + imply in relationship dynamics. + </p> + </div> + + {% for group_key, group in groups.items %} + <div class="column is-12" id="group-{{ group_key }}"> + <div class="box"> + <p class="heading">{{ group.title }}</p> + <p style="margin-bottom: 0.75rem;">{{ group.summary }}</p> + + {% for metric in metrics %} + {% if metric.group == group_key %} + <article class="message is-light" style="margin-bottom: 0.6rem;"> + <div class="message-body"> + <p><strong>{{ metric.title }}</strong>: {{ metric.value|default:"-" }}</p> + <p><strong>Calculation:</strong> {{ metric.calculation }}</p> + <p><strong>Psychological Read:</strong> {{ metric.psychology }}</p> + </div> + </article> + {% endif %} + {% endfor %} + </div> + </div> + {% endfor %} + </div> +{% endblock %} diff --git a/core/templates/pages/ai-workspace.html b/core/templates/pages/ai-workspace.html index 1768a30..4e4cdb0 100644 --- a/core/templates/pages/ai-workspace.html +++ b/core/templates/pages/ai-workspace.html @@ -8,4 +8,13 @@ hx-trigger="load" hx-swap="afterend" style="display: none;"></div> + {% if selected_person_id %} + <div + hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' + hx-get="{% url 'ai_workspace_person' type='widget' person_id=selected_person_id %}" + hx-target="#widgets-here" + hx-trigger="load delay:250ms" + hx-swap="afterend" + style="display: none;"></div> + {% endif %} {% endblock %} diff --git a/core/templates/pages/compose.html b/core/templates/pages/compose.html new file mode 100644 index 0000000..cd5d869 --- /dev/null +++ b/core/templates/pages/compose.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block content %} + <div class="columns is-centered"> + <div class="column is-10-tablet is-9-desktop is-8-widescreen"> + <div id="compose-page-panel"> + {% include "partials/compose-panel.html" %} + </div> + </div> + </div> +{% endblock %} diff --git a/core/templates/pages/signal.html b/core/templates/pages/signal.html index afe0a02..56251ce 100644 --- a/core/templates/pages/signal.html +++ b/core/templates/pages/signal.html @@ -3,9 +3,9 @@ {% block load_widgets %} <div hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' - hx-get="{% url 'signal_accounts' type='widget' %}" + hx-get="{% url accounts_url_name type='widget' %}" hx-target="#widgets-here" hx-trigger="load" hx-swap="afterend" style="display: none;"></div> -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/core/templates/partials/ai-workspace-ai-result.html b/core/templates/partials/ai-workspace-ai-result.html index 61af2ea..ddf1d2f 100644 --- a/core/templates/partials/ai-workspace-ai-result.html +++ b/core/templates/partials/ai-workspace-ai-result.html @@ -13,6 +13,37 @@ {{ result_text }} </div> {% else %} + {% if ai_request_status %} + <div class="tags" style="margin-bottom: 0.45rem;"> + <span class="tag is-light">Request {{ ai_request_status|title }}</span> + <span class="tag is-light">Messages {{ ai_request_message_count|default:0 }}</span> + {% if ai_result_created_at %} + <span class="tag is-light">Result {{ ai_result_created_at }}</span> + {% endif %} + {% if ai_request_started_at %} + <span class="tag is-light">Started {{ ai_request_started_at }}</span> + {% endif %} + {% if ai_request_finished_at %} + <span class="tag is-light">Finished {{ ai_request_finished_at }}</span> + {% endif %} + </div> + {% if ai_request_window_spec %} + <div class="tags" style="margin-bottom: 0.45rem;"> + {% if ai_request_window_tags %} + {% for item in ai_request_window_tags %} + <span class="tag is-light">{{ item }}</span> + {% endfor %} + {% else %} + <span class="tag is-light">Window selected</span> + {% endif %} + {% if ai_request_policy_tags %} + {% for item in ai_request_policy_tags %} + <span class="tag is-light">{{ item }}</span> + {% endfor %} + {% endif %} + </div> + {% endif %} + {% endif %} {% if operation == "artifacts" %} {% if latest_plan %} {% include "partials/ai-workspace-mitigation-panel.html" with person=person plan=latest_plan rules=latest_plan_rules games=latest_plan_games corrections=latest_plan_corrections fundamentals_text=latest_plan.fundamental_items|join:"\n" mitigation_messages=latest_plan_messages latest_export=latest_plan_export notice_message=mitigation_notice_message notice_level=mitigation_notice_level auto_settings=latest_auto_settings active_tab="plan_board" %} @@ -145,6 +176,72 @@ {% endif %} {% endif %} + {% if interaction_signals %} + <article class="box" style="padding: 0.55rem; margin-top: 0.55rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;"> + <p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">Interaction Signals</p> + <div class="tags"> + {% for signal in interaction_signals %} + <span class="tag is-light">{{ signal.label }} ({{ signal.valence }})</span> + {% endfor %} + </div> + </article> + {% endif %} + + {% if memory_proposals %} + <article class="box" style="padding: 0.55rem; margin-top: 0.55rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;"> + <p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">Memory Proposals</p> + {% if memory_proposal_groups %} + <div class="columns is-multiline" style="margin: 0 -0.25rem;"> + {% for group in memory_proposal_groups %} + <div class="column is-12-mobile is-6-tablet" style="padding: 0.25rem;"> + <article class="box" style="padding: 0.45rem; margin-bottom: 0; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;"> + <p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.3rem;">{{ group.title }}</p> + <ul style="margin: 0 0 0.25rem 1.1rem;"> + {% for proposal in group.items %} + <li class="is-size-7" style="margin-bottom: 0.22rem; white-space: pre-wrap;"> + {{ proposal.content }} + </li> + {% endfor %} + </ul> + </article> + </div> + {% endfor %} + </div> + {% else %} + {% for proposal in memory_proposals %} + <p class="is-size-7" style="margin-bottom: 0.3rem; white-space: pre-wrap;"> + <strong>{{ proposal.kind|title }}</strong>: {{ proposal.content }} + </p> + {% endfor %} + {% endif %} + </article> + {% endif %} + + {% if citations %} + <article class="box" style="padding: 0.55rem; margin-top: 0.55rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;"> + <p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">Citations</p> + {% if citation_rows %} + <div class="content is-small" style="margin-bottom: 0;"> + {% for row in citation_rows %} + <p class="is-size-7" style="margin-bottom: 0.3rem;"> + <span class="tag is-light">{{ row.source_system|default:"event" }}</span> + {% if row.ts_label %} + <span class="has-text-grey">{{ row.ts_label }}</span> + {% endif %} + {% if row.text %} + <span> {{ row.text|truncatechars:140 }}</span> + {% else %} + <code>{{ row.id }}</code> + {% endif %} + </p> + {% endfor %} + </div> + {% else %} + <p class="is-size-7" style="margin: 0;">{{ citations|join:", " }}</p> + {% endif %} + </article> + {% endif %} + {% if operation == "extract_patterns" %} <article class="box" style="padding: 0.7rem; margin-top: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;"> <p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.4rem;">Create Framework / Rules / Games</p> diff --git a/core/templates/partials/ai-workspace-mitigation-panel.html b/core/templates/partials/ai-workspace-mitigation-panel.html index 5560640..3e4f0c3 100644 --- a/core/templates/partials/ai-workspace-mitigation-panel.html +++ b/core/templates/partials/ai-workspace-mitigation-panel.html @@ -7,7 +7,54 @@ <p class="is-size-7">{{ plan.objective }}</p> {% endif %} </div> - <span class="tag is-light">{{ plan.creation_mode|title }}</span> + <div class="is-flex is-flex-direction-column" style="gap: 0.35rem;"> + <span class="tag is-light">{{ plan.creation_mode|title }} / {{ plan.status|title }}</span> + <span class="tag is-light">Created {{ plan.created_at }}</span> + <span class="tag is-light">Updated {{ plan.updated_at }}</span> + {% if plan.source_ai_result_id %} + <span class="tag is-light">Source Result {{ plan.source_ai_result_id }}</span> + {% endif %} + <form + hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' + hx-post="{% url 'ai_workspace_mitigation_meta_save' type='widget' person_id=person.id plan_id=plan.id %}" + hx-target="#mitigation-shell-{{ person.id }}" + hx-swap="outerHTML"> + <input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}"> + <div class="field" style="margin-bottom: 0.3rem;"> + <div class="control"> + <input class="input is-small" type="text" name="title" value="{{ plan.title }}" placeholder="Plan title"> + </div> + </div> + <div class="field" style="margin-bottom: 0.3rem;"> + <div class="control"> + <textarea class="textarea is-small" rows="2" name="objective" placeholder="Plan objective">{{ plan.objective }}</textarea> + </div> + </div> + <div class="field is-grouped is-grouped-right" style="margin: 0; gap: 0.3rem;"> + <div class="control"> + <div class="select is-small"> + <select name="creation_mode"> + {% for value, label in plan_creation_mode_choices %} + <option value="{{ value }}" {% if plan.creation_mode == value %}selected{% endif %}>{{ label }}</option> + {% endfor %} + </select> + </div> + </div> + <div class="control"> + <div class="select is-small"> + <select name="status"> + {% for value, label in plan_status_choices %} + <option value="{{ value }}" {% if plan.status == value %}selected{% endif %}>{{ label }}</option> + {% endfor %} + </select> + </div> + </div> + <div class="control"> + <button type="submit" class="button is-small is-light">Save</button> + </div> + </div> + </form> + </div> </div> {% if notice_message %} @@ -84,6 +131,7 @@ {% for rule in rules %} <article class="box" style="padding: 0.55rem; margin-bottom: 0.45rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;"> <span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Rule</span> + <span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Created {{ rule.created_at }}</span> <form hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='rule' artifact_id=rule.id %}" @@ -95,7 +143,10 @@ <div class="field" style="margin-bottom: 0.35rem;"> <textarea class="textarea is-small" rows="3" name="body" data-editable="1" readonly>{{ rule.content }}</textarea> </div> - <input type="hidden" name="enabled" value="1"> + <label class="checkbox is-size-7" style="margin-bottom: 0.35rem;"> + <input type="checkbox" name="enabled" value="1" {% if rule.enabled %}checked{% endif %}> + Enabled + </label> <input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}"> <div class="buttons are-small" style="margin: 0;"> <button type="button" class="button is-link is-light" data-edit-state="view" onclick="giaMitigationToggleEdit(this); return false;">Edit</button> @@ -136,6 +187,7 @@ {% for game in games %} <article class="box" style="padding: 0.55rem; margin-bottom: 0.45rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;"> <span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Game</span> + <span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Created {{ game.created_at }}</span> <form hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='game' artifact_id=game.id %}" @@ -147,7 +199,10 @@ <div class="field" style="margin-bottom: 0.35rem;"> <textarea class="textarea is-small" rows="3" name="body" data-editable="1" readonly>{{ game.instructions }}</textarea> </div> - <input type="hidden" name="enabled" value="1"> + <label class="checkbox is-size-7" style="margin-bottom: 0.35rem;"> + <input type="checkbox" name="enabled" value="1" {% if game.enabled %}checked{% endif %}> + Enabled + </label> <input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}"> <div class="buttons are-small" style="margin: 0;"> <button type="button" class="button is-link is-light" data-edit-state="view" onclick="giaMitigationToggleEdit(this); return false;">Edit</button> @@ -203,6 +258,7 @@ {% for correction in corrections %} <article class="box" style="padding: 0.55rem; margin-bottom: 0.5rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;"> <span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Correction</span> + <span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Created {{ correction.created_at }}</span> <form hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='correction' artifact_id=correction.id %}" @@ -220,8 +276,41 @@ <label class="label is-small" style="margin-bottom: 0.2rem;">Insight</label> <textarea class="textarea is-small" rows="2" name="body">{{ correction.clarification }}</textarea> </div> + <div class="column is-12-mobile is-4-tablet" style="padding: 0.3rem;"> + <label class="label is-small" style="margin-bottom: 0.2rem;">Perspective</label> + <div class="select is-small is-fullwidth"> + <select name="perspective"> + {% for value, label in correction.PERSPECTIVE_CHOICES %} + <option value="{{ value }}" {% if correction.perspective == value %}selected{% endif %}>{{ label }}</option> + {% endfor %} + </select> + </div> + </div> + <div class="column is-12-mobile is-4-tablet" style="padding: 0.3rem;"> + <label class="label is-small" style="margin-bottom: 0.2rem;">Share Target</label> + <div class="select is-small is-fullwidth"> + <select name="share_target"> + {% for value, label in correction.SHARE_TARGET_CHOICES %} + <option value="{{ value }}" {% if correction.share_target == value %}selected{% endif %}>{{ label }}</option> + {% endfor %} + </select> + </div> + </div> + <div class="column is-12-mobile is-4-tablet" style="padding: 0.3rem;"> + <label class="label is-small" style="margin-bottom: 0.2rem;">Language Style</label> + <div class="select is-small is-fullwidth"> + <select name="language_style"> + {% for value, label in correction.LANGUAGE_STYLE_CHOICES %} + <option value="{{ value }}" {% if correction.language_style == value %}selected{% endif %}>{{ label }}</option> + {% endfor %} + </select> + </div> + </div> </div> - <input type="hidden" name="enabled" value="1"> + <label class="checkbox is-size-7" style="margin-bottom: 0.35rem;"> + <input type="checkbox" name="enabled" value="1" {% if correction.enabled %}checked{% endif %}> + Enabled + </label> <input type="hidden" name="active_tab" value="{{ active_tab|default:'corrections' }}"> <div class="buttons are-small" style="margin: 0;"> <button class="button is-small is-link is-light">Save Correction</button> @@ -395,6 +484,12 @@ <p class="is-size-7" style="margin-bottom: 0;"> Last run: {% if auto_settings.last_run_at %}{{ auto_settings.last_run_at }}{% else %}Never{% endif %} </p> + <p class="is-size-7" style="margin-bottom: 0;"> + Created: {{ auto_settings.created_at }} | Updated: {{ auto_settings.updated_at }} + </p> + <p class="is-size-7" style="margin-bottom: 0;"> + Last checked event ts: {{ auto_settings.last_checked_event_ts|default:"None" }} + </p> {% if auto_settings.last_result_summary %} <p class="is-size-7" style="margin-top: 0.35rem; margin-bottom: 0;">{{ auto_settings.last_result_summary }}</p> {% endif %} @@ -463,10 +558,9 @@ <label class="label is-small" style="margin-bottom: 0.25rem;">Bundle</label> <div class="select is-small"> <select name="artifact_type"> - <option value="rulebook">Rulebook</option> - <option value="rules">Rules</option> - <option value="games">Games</option> - <option value="corrections">Corrections</option> + {% for value, label in artifact_type_choices %} + <option value="{{ value }}">{{ label }}</option> + {% endfor %} </select> </div> </div> @@ -474,9 +568,9 @@ <label class="label is-small" style="margin-bottom: 0.25rem;">Format</label> <div class="select is-small"> <select name="export_format"> - <option value="markdown">Markdown</option> - <option value="json">JSON</option> - <option value="text">Text</option> + {% for value, label in artifact_format_choices %} + <option value="{{ value }}">{{ label }}</option> + {% endfor %} </select> </div> </div> @@ -494,6 +588,11 @@ <p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.3rem;"> Last Export: {{ latest_export.artifact_type|title }} ({{ latest_export.export_format|upper }}) </p> + <p class="is-size-7" style="margin-bottom: 0.3rem;"> + Created {{ latest_export.created_at }} | + Protocol {{ latest_export.protocol_version }} | + Meta {{ latest_export.meta }} + </p> <pre style="max-height: 14rem; overflow: auto; margin: 0; white-space: pre-wrap; font-size: 0.72rem; line-height: 1.28;">{{ latest_export.payload }}</pre> </article> {% endif %} @@ -505,6 +604,7 @@ {% for message in mitigation_messages %} <div style="margin-bottom: 0.45rem;"> <span class="tag is-light is-small">{{ message.role }}</span> + <span class="tag is-light is-small">{{ message.created_at }}</span> <div style="margin-top: 0.15rem; white-space: pre-wrap;">{{ message.text }}</div> </div> {% empty %} @@ -560,9 +660,9 @@ const forceInput = document.getElementById("engage-force-send-" + pid); const sendBtn = document.getElementById("engage-send-btn-" + pid); const force = - !!(window.giaWorkspaceState - && window.giaWorkspaceState[pid] - && window.giaWorkspaceState[pid].forceSend); + !!(window.giaWorkspaceState + && window.giaWorkspaceState[pid] + && window.giaWorkspaceState[pid].forceSend); if (forceInput) { forceInput.value = force ? "1" : "0"; } diff --git a/core/templates/partials/ai-workspace-person-widget.html b/core/templates/partials/ai-workspace-person-widget.html index d9c92d4..972a91f 100644 --- a/core/templates/partials/ai-workspace-person-widget.html +++ b/core/templates/partials/ai-workspace-person-widget.html @@ -10,6 +10,57 @@ <p class="is-size-7 has-text-weight-semibold">Selected Person</p> <h3 class="title is-5" style="margin-bottom: 0.25rem;">{{ person.name }}</h3> <p class="is-size-7">Showing last {{ limit }} messages.</p> + <div class="tags" style="margin-top: 0.35rem;"> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='platform' %}">Platform {{ workspace_conversation.platform_type|title }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='thread' %}">Thread {{ workspace_conversation.platform_thread_id|default:"-" }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='workspace_created' %}">Workspace Created {{ workspace_conversation.created_at|default:"-" }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='stability_state' %}">Stability {{ workspace_conversation.stability_state|title }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='stability_score' %}">Stability Score {{ workspace_conversation.stability_score|default:"-" }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='stability_confidence' %}">Confidence {{ workspace_conversation.stability_confidence }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='sample_messages' %}">Sample Msg {{ workspace_conversation.stability_sample_messages }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='sample_days' %}">Sample Days {{ workspace_conversation.stability_sample_days }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='stability_computed' %}">Stability Computed {{ workspace_conversation.stability_last_computed_at|default:"-" }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='commitment_inbound' %}">Commit In {{ workspace_conversation.commitment_inbound_score|default:"-" }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='commitment_outbound' %}">Commit Out {{ workspace_conversation.commitment_outbound_score|default:"-" }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='commitment_confidence' %}">Commit Confidence {{ workspace_conversation.commitment_confidence }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='commitment_computed' %}">Commitment Computed {{ workspace_conversation.commitment_last_computed_at|default:"-" }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='last_event' %}">Last Event {{ workspace_conversation.last_event_ts|default:"-" }}</a> + <a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='last_ai_run' %}">Last AI Run {{ workspace_conversation.last_ai_run_at|default:"-" }}</a> + </div> + <div class="buttons are-small" style="margin-top: 0.35rem; margin-bottom: 0;"> + <a class="button is-light" href="{% url 'ai_workspace_insight_graphs' type='page' person_id=person.id %}"> + <span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span> + <span>Insight Graphs</span> + </a> + <a class="button is-light" href="{% url 'ai_workspace_insight_help' type='page' person_id=person.id %}"> + <span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span> + <span>Scoring Help</span> + </a> + </div> + {% with participants=workspace_conversation.participants.all %} + {% if participants %} + <p class="is-size-7" style="margin-top: 0.35rem; margin-bottom: 0;"> + Participants: + {% for participant in participants %} + {% if not forloop.first %}, {% endif %} + {{ participant.name }} + {% endfor %} + </p> + {% endif %} + {% endwith %} + {% if workspace_conversation.participant_feedback %} + <p class="is-size-7" style="margin-top: 0.35rem; margin-bottom: 0;"> + Participant Feedback: {{ workspace_conversation.participant_feedback }} + </p> + {% endif %} + {% if compose_page_url %} + <div class="buttons are-small" style="margin-top: 0.45rem; margin-bottom: 0;"> + <a class="button is-light" href="{{ compose_page_url }}"> + <span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span> + <span>Manual Text Mode</span> + </a> + </div> + {% endif %} </div> <div class="notification is-{{ send_state.level }} is-light" style="padding: 0.5rem 0.75rem;"> @@ -459,32 +510,32 @@ fetch(url, { method: "GET" }) .then(function(resp) { return resp.text(); }) .then(function(html) { - pane.innerHTML = html; - pane.dataset.loaded = "1"; - executeInlineScripts(pane); - pane.classList.remove("ai-animate-in"); - void pane.offsetWidth; - pane.classList.add("ai-animate-in"); - if (cacheAllowed) { - window.giaWorkspaceCache[key] = { - html: html, - ts: Date.now(), - }; - persistCache(); - setCachedIndicator(true, window.giaWorkspaceCache[key].ts); - } else { - setCachedIndicator(false, null); - } - if (window.htmx) { - window.htmx.process(pane); - } - if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") { - window.giaWorkspaceUseDraft(personId, operation, 0); - } - }) + pane.innerHTML = html; + pane.dataset.loaded = "1"; + executeInlineScripts(pane); + pane.classList.remove("ai-animate-in"); + void pane.offsetWidth; + pane.classList.add("ai-animate-in"); + if (cacheAllowed) { + window.giaWorkspaceCache[key] = { + html: html, + ts: Date.now(), + }; + persistCache(); + setCachedIndicator(true, window.giaWorkspaceCache[key].ts); + } else { + setCachedIndicator(false, null); + } + if (window.htmx) { + window.htmx.process(pane); + } + if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") { + window.giaWorkspaceUseDraft(personId, operation, 0); + } + }) .catch(function() { - pane.innerHTML = '<div class="notification is-danger is-light ai-animate-in">Failed to load AI response.</div>'; - }); + pane.innerHTML = '<div class="notification is-danger is-light ai-animate-in">Failed to load AI response.</div>'; + }); }; window.giaWorkspaceRefresh = function(pid) { @@ -576,15 +627,15 @@ }) .then(function(resp) { return resp.text(); }) .then(function(html) { - if (statusHost) { - statusHost.innerHTML = html; - } - }) + if (statusHost) { + statusHost.innerHTML = html; + } + }) .catch(function() { - if (statusHost) { - statusHost.innerHTML = '<div class="notification is-danger is-light" style="padding: 0.45rem 0.6rem;">Failed to queue draft.</div>'; - } - }); + if (statusHost) { + statusHost.innerHTML = '<div class="notification is-danger is-light" style="padding: 0.45rem 0.6rem;">Failed to queue draft.</div>'; + } + }); }; if (typeof window.giaMitigationShowTab !== "function") { diff --git a/core/templates/partials/compose-panel.html b/core/templates/partials/compose-panel.html new file mode 100644 index 0000000..42cfd1d --- /dev/null +++ b/core/templates/partials/compose-panel.html @@ -0,0 +1,341 @@ +<div id="{{ panel_id }}" class="compose-shell"> + <div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; flex-wrap: wrap;"> + <div> + <p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.2rem;">Manual Text Mode</p> + <p class="is-size-6" style="margin-bottom: 0;"> + {% if person %} + {{ person.name }} + {% else %} + {{ identifier }} + {% endif %} + </p> + <p class="is-size-7 compose-meta-line" style="margin-bottom: 0;"> + {{ service|title }} · {{ identifier }} + </p> + </div> + <div class="buttons are-small" style="margin: 0;"> + <a class="button is-light is-rounded" href="{{ ai_workspace_url }}"> + <span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span> + <span>AI Workspace</span> + </a> + </div> + </div> + + <div id="{{ panel_id }}-status" class="compose-status"> + {% include "partials/compose-send-status.html" %} + </div> + + <div + id="{{ panel_id }}-thread" + class="compose-thread" + data-poll-url="{% url 'compose_thread' %}" + data-service="{{ service }}" + data-identifier="{{ identifier }}" + data-person="{% if person %}{{ person.id }}{% endif %}" + data-limit="{{ limit }}" + data-last-ts="{{ last_ts }}"> + {% for msg in serialized_messages %} + <div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}"> + <article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}"> + <p class="compose-body">{{ msg.text|default:"(no text)" }}</p> + <p class="compose-msg-meta"> + {{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %} + </p> + </article> + </div> + {% empty %} + <p class="compose-empty">No stored messages for this contact yet.</p> + {% endfor %} + </div> + + <form + id="{{ panel_id }}-form" + class="compose-form" + hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' + hx-post="{% url 'compose_send' %}" + hx-target="#{{ panel_id }}-status" + hx-swap="innerHTML"> + <input type="hidden" name="service" value="{{ service }}"> + <input type="hidden" name="identifier" value="{{ identifier }}"> + <input type="hidden" name="render_mode" value="{{ render_mode }}"> + <input type="hidden" name="limit" value="{{ limit }}"> + {% if person %} + <input type="hidden" name="person" value="{{ person.id }}"> + {% endif %} + <div class="compose-composer-capsule"> + <textarea + id="{{ panel_id }}-textarea" + class="compose-textarea" + name="text" + rows="1" + placeholder="Type a message. Enter to send, Shift+Enter for newline."></textarea> + <button class="button is-link is-light compose-send-btn" type="submit"> + <span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span> + <span>Send</span> + </button> + </div> + </form> +</div> + +<style> + #{{ panel_id }}.compose-shell { + border: 1px solid rgba(0, 0, 0, 0.16); + border-radius: 8px; + box-shadow: none; + padding: 0.7rem; + background: #fff; + } + #{{ panel_id }} .compose-thread { + margin-top: 0.55rem; + margin-bottom: 0.55rem; + min-height: 16rem; + max-height: 62vh; + overflow-y: auto; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + padding: 0.65rem; + background: linear-gradient(180deg, rgba(248, 250, 252, 0.7), rgba(255, 255, 255, 0.98)); + } + #{{ panel_id }} .compose-row { + display: flex; + margin-bottom: 0.5rem; + } + #{{ panel_id }} .compose-row.is-in { + justify-content: flex-start; + } + #{{ panel_id }} .compose-row.is-out { + justify-content: flex-end; + } + #{{ panel_id }} .compose-bubble { + max-width: min(85%, 46rem); + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.13); + padding: 0.52rem 0.62rem; + box-shadow: none; + } + #{{ panel_id }} .compose-bubble.is-in { + background: rgba(255, 255, 255, 0.96); + } + #{{ panel_id }} .compose-bubble.is-out { + background: #eef6ff; + } + #{{ panel_id }} .compose-body { + margin: 0 0 0.2rem 0; + white-space: pre-wrap; + word-break: break-word; + } + #{{ panel_id }} .compose-msg-meta, + #{{ panel_id }} .compose-meta-line { + color: #616161; + font-size: 0.72rem; + } + #{{ panel_id }} .compose-msg-meta { + margin: 0; + } + #{{ panel_id }} .compose-empty { + margin: 0; + color: #6f6f6f; + font-size: 0.78rem; + } + #{{ panel_id }} .compose-composer-capsule { + display: flex; + align-items: flex-end; + gap: 0.45rem; + border: 1px solid rgba(0, 0, 0, 0.16); + border-radius: 8px; + background: #fff; + padding: 0.35rem; + } + #{{ panel_id }} .compose-textarea { + flex: 1 1 auto; + min-height: 2.45rem; + max-height: 8rem; + resize: none; + border: none; + box-shadow: none; + outline: none; + background: transparent; + line-height: 1.35; + font-size: 0.98rem; + padding: 0.45rem 0.5rem; + } + #{{ panel_id }} .compose-send-btn { + height: 2.45rem; + border-radius: 8px; + margin: 0; + } + #{{ panel_id }} .compose-status { + margin-top: 0.55rem; + min-height: 1.1rem; + } + @media (max-width: 768px) { + #{{ panel_id }} .compose-thread { + max-height: 52vh; + } + #{{ panel_id }} .compose-send-btn span:last-child { + display: none; + } + } +</style> + +<script> + (function () { + const panelId = "{{ panel_id }}"; + const panel = document.getElementById(panelId); + if (!panel) { + return; + } + const thread = document.getElementById(panelId + "-thread"); + const form = document.getElementById(panelId + "-form"); + const textarea = document.getElementById(panelId + "-textarea"); + if (!thread || !form || !textarea) { + return; + } + + window.giaComposePanels = window.giaComposePanels || {}; + const previousState = window.giaComposePanels[panelId]; + if (previousState && previousState.timer) { + clearInterval(previousState.timer); + } + if (previousState && previousState.eventHandler) { + document.body.removeEventListener("composeMessageSent", previousState.eventHandler); + } + const panelState = { timer: null, polling: false }; + window.giaComposePanels[panelId] = panelState; + + const toInt = function (value) { + const parsed = parseInt(value || "0", 10); + return Number.isFinite(parsed) ? parsed : 0; + }; + + let lastTs = toInt(thread.dataset.lastTs); + + const autosize = function () { + textarea.style.height = "auto"; + const targetHeight = Math.min(Math.max(textarea.scrollHeight, 40), 128); + textarea.style.height = targetHeight + "px"; + }; + textarea.addEventListener("input", autosize); + autosize(); + + const nearBottom = function () { + return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 100; + }; + + const scrollToBottom = function (force) { + if (force || nearBottom()) { + thread.scrollTop = thread.scrollHeight; + } + }; + + const appendBubble = function (msg) { + const row = document.createElement("div"); + const outgoing = !!msg.outgoing; + row.className = "compose-row " + (outgoing ? "is-out" : "is-in"); + row.dataset.ts = String(msg.ts || 0); + + const bubble = document.createElement("article"); + bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in"); + + const body = document.createElement("p"); + body.className = "compose-body"; + body.textContent = String(msg.text || "(no text)"); + bubble.appendChild(body); + + const meta = document.createElement("p"); + meta.className = "compose-msg-meta"; + let metaText = String(msg.display_ts || msg.ts || ""); + if (msg.author) { + metaText += " · " + String(msg.author); + } + meta.textContent = metaText; + bubble.appendChild(meta); + + row.appendChild(bubble); + const empty = thread.querySelector(".compose-empty"); + if (empty) { + empty.remove(); + } + thread.appendChild(row); + }; + + const poll = async function (forceScroll) { + if (panelState.polling) { + return; + } + panelState.polling = true; + try { + const params = new URLSearchParams(); + params.set("service", thread.dataset.service || ""); + params.set("identifier", thread.dataset.identifier || ""); + if (thread.dataset.person) { + params.set("person", thread.dataset.person); + } + params.set("limit", thread.dataset.limit || "60"); + params.set("after_ts", String(lastTs)); + const url = thread.dataset.pollUrl + "?" + params.toString(); + const response = await fetch(url, { + method: "GET", + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + return; + } + const payload = await response.json(); + const messages = Array.isArray(payload.messages) ? payload.messages : []; + const shouldStick = nearBottom() || forceScroll; + messages.forEach(function (msg) { + appendBubble(msg); + lastTs = Math.max(lastTs, toInt(msg.ts)); + }); + if (payload.last_ts !== undefined && payload.last_ts !== null) { + lastTs = Math.max(lastTs, toInt(payload.last_ts)); + } + thread.dataset.lastTs = String(lastTs); + if (messages.length > 0) { + scrollToBottom(shouldStick); + } + } catch (err) { + console.debug("compose poll error", err); + } finally { + panelState.polling = false; + } + }; + + textarea.addEventListener("keydown", function (event) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + form.requestSubmit(); + } + }); + + form.addEventListener("htmx:afterRequest", function (event) { + if (event.detail && event.detail.successful) { + textarea.value = ""; + autosize(); + poll(true); + textarea.focus(); + } + }); + + panelState.eventHandler = function () { + poll(true); + }; + document.body.addEventListener("composeMessageSent", panelState.eventHandler); + + scrollToBottom(true); + panelState.timer = setInterval(function () { + if (!document.getElementById(panelId)) { + clearInterval(panelState.timer); + document.body.removeEventListener( + "composeMessageSent", + panelState.eventHandler + ); + delete window.giaComposePanels[panelId]; + return; + } + poll(false); + }, 1800); + })(); +</script> diff --git a/core/templates/partials/compose-send-status.html b/core/templates/partials/compose-send-status.html new file mode 100644 index 0000000..3c982c3 --- /dev/null +++ b/core/templates/partials/compose-send-status.html @@ -0,0 +1,5 @@ +{% if notice_message %} + <article class="notification is-{{ notice_level|default:'info' }} is-light" style="padding: 0.45rem 0.65rem; margin: 0;"> + {{ notice_message }} + </article> +{% endif %} diff --git a/core/templates/partials/message-list.html b/core/templates/partials/message-list.html index 7faf688..c7f7bd3 100644 --- a/core/templates/partials/message-list.html +++ b/core/templates/partials/message-list.html @@ -17,6 +17,10 @@ <th>sender</th> <th>text</th> <th>author</th> + <th>delivered ts</th> + <th>read ts</th> + <th>read service</th> + <th>read by</th> <th>actions</th> </thead> {% for item in object_list %} @@ -43,6 +47,10 @@ </td> <td>{{ item.text }}</td> <td>{{ item.custom_author }}</td> + <td>{{ item.delivered_ts }}</td> + <td>{{ item.read_ts }}</td> + <td>{{ item.read_source_service }}</td> + <td>{{ item.read_by_identifier }}</td> <td> <div class="buttons"> <button @@ -78,4 +86,4 @@ {% endfor %} </table> -{% endcache %} \ No newline at end of file +{% endcache %} diff --git a/core/templates/partials/nav-contacts-dropdown.html b/core/templates/partials/nav-contacts-dropdown.html new file mode 100644 index 0000000..6f739ba --- /dev/null +++ b/core/templates/partials/nav-contacts-dropdown.html @@ -0,0 +1,13 @@ +{% if items %} + {% for item in items %} + <a class="navbar-item" href="{{ item.compose_url }}"> + <span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span> + <span style="margin-left: 0.35rem;"> + {{ item.person_name }} · {{ item.service|title }} + </span> + </a> + {% endfor %} +{% else %} + <a class="navbar-item is-disabled">No contacts found.</a> +{% endif %} + diff --git a/core/templates/partials/session-list.html b/core/templates/partials/session-list.html index ca121b6..a95d431 100644 --- a/core/templates/partials/session-list.html +++ b/core/templates/partials/session-list.html @@ -14,6 +14,7 @@ <th>id</th> <th>identifier</th> <th>last interaction</th> + <th>summary</th> <th>actions</th> </thead> {% for item in object_list %} @@ -29,6 +30,7 @@ </td> <td>{{ item.identifier }}</td> <td>{{ item.last_interaction }}</td> + <td>{{ item.summary|default:"" }}</td> <td> <div class="buttons"> <button @@ -73,4 +75,4 @@ {% endfor %} </table> -{% endcache %} \ No newline at end of file +{% endcache %} diff --git a/core/templates/partials/signal-account-add.html b/core/templates/partials/signal-account-add.html index 59a00c6..f15dd15 100644 --- a/core/templates/partials/signal-account-add.html +++ b/core/templates/partials/signal-account-add.html @@ -1,2 +1 @@ - -<img src="data:image/png;base64, {{ object }}" alt="Signal QR code" /> \ No newline at end of file +<img src="data:image/png;base64, {{ object }}" alt="Service QR code" /> diff --git a/core/templates/partials/signal-accounts.html b/core/templates/partials/signal-accounts.html index 7214b8a..bcc390e 100644 --- a/core/templates/partials/signal-accounts.html +++ b/core/templates/partials/signal-accounts.html @@ -1,6 +1,11 @@ {% load cache %} {% include 'mixins/partials/notify.html' %} -{% cache 600 objects_signal_accounts request.user.id object_list type %} +{% cache 600 objects_signal_accounts request.user.id object_list type service %} + {% if service_warning %} + <article class="notification is-warning is-light" style="margin-bottom: 0.55rem;"> + {{ service_warning }} + </article> + {% endif %} <table class="table is-fullwidth is-hoverable" hx-target="#{{ context_object_name }}-table" @@ -9,7 +14,7 @@ hx-trigger="{{ context_object_name_singular }}Event from:body" hx-get="{{ list_url }}"> <thead> - <th>number</th> + <th>{{ service_label|default:"Service" }} account</th> <th>actions</th> </thead> {% for item in object_list %} @@ -31,52 +36,54 @@ </span> </span> </button> - {% if type == 'page' %} - <a href="{% url 'signal_contacts' type=type pk=item %}"><button - class="button"> - <span class="icon-text"> - <span class="icon"> - <i class="fa-solid fa-eye"></i> + {% if show_contact_actions %} + {% if type == 'page' %} + <a href="{% url 'signal_contacts' type=type pk=item %}"><button + class="button"> + <span class="icon-text"> + <span class="icon"> + <i class="fa-solid fa-eye"></i> + </span> </span> - </span> - </button> - </a> - <a href="{% url 'signal_chats' type=type pk=item %}"><button - class="button"> - <span class="icon-text"> - <span class="icon"> - <i class="fa-solid fa-envelope"></i> + </button> + </a> + <a href="{% url 'signal_chats' type=type pk=item %}"><button + class="button"> + <span class="icon-text"> + <span class="icon"> + <i class="fa-solid fa-envelope"></i> + </span> </span> - </span> - </button> - </a> - {% else %} - <button - hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' - hx-get="{% url 'signal_contacts' type=type pk=item %}" - hx-trigger="click" - hx-target="#{{ type }}s-here" - hx-swap="innerHTML" - class="button"> - <span class="icon-text"> - <span class="icon"> - <i class="fa-solid fa-eye"></i> + </button> + </a> + {% else %} + <button + hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' + hx-get="{% url 'signal_contacts' type=type pk=item %}" + hx-trigger="click" + hx-target="#{{ type }}s-here" + hx-swap="innerHTML" + class="button"> + <span class="icon-text"> + <span class="icon"> + <i class="fa-solid fa-eye"></i> + </span> </span> - </span> - </button> - <button - hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' - hx-get="{% url 'signal_chats' type=type pk=item %}" - hx-trigger="click" - hx-target="#{{ type }}s-here" - hx-swap="innerHTML" - class="button"> - <span class="icon-text"> - <span class="icon"> - <i class="fa-solid fa-envelope"></i> + </button> + <button + hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' + hx-get="{% url 'signal_chats' type=type pk=item %}" + hx-trigger="click" + hx-target="#{{ type }}s-here" + hx-swap="innerHTML" + class="button"> + <span class="icon-text"> + <span class="icon"> + <i class="fa-solid fa-envelope"></i> + </span> </span> - </span> - </button> + </button> + {% endif %} {% endif %} </div> </td> @@ -86,19 +93,17 @@ </table> <form hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' - hx-post="{% url 'signal_account_add' type=type %}" - hx-target="#modals-here" + hx-post="{% url account_add_url_name type=type %}" + hx-target="#widgets-here" hx-swap="innerHTML"> {% csrf_token %} <div class="field has-addons"> <div id="device" class="control is-expanded has-icons-left"> <input - hx-post="{% url 'signal_account_add' type=type %}" - hx-target="#widgets-here" - hx-swap="innerHTML" name="device" class="input" type="text" + required placeholder="Account name"> <span class="icon is-small is-left"> <i class="fa-solid fa-plus"></i> @@ -108,15 +113,13 @@ <div class="field"> <button id="search" + type="submit" class="button is-fullwidth" - hx-post="{% url 'signal_account_add' type=type %}" - hx-trigger="click" - hx-target="#widgets-here" - hx-swap="innerHTML"> + > Add account </button> </div> </div> </div> </form> -{% endcache %} \ No newline at end of file +{% endcache %} diff --git a/core/templates/partials/signal-chats-list.html b/core/templates/partials/signal-chats-list.html index 485d084..0b98f6e 100644 --- a/core/templates/partials/signal-chats-list.html +++ b/core/templates/partials/signal-chats-list.html @@ -13,22 +13,24 @@ <th>uuid</th> <th>account</th> <th>name</th> + <th>person</th> <th>actions</th> </thead> {% for item in object_list %} <tr> - <td>{{ item.source_number }}</td> + <td>{{ item.chat.source_number }}</td> <td> <a class="has-text-grey button nowrap-child" - onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.source_uuid }}');"> + onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.chat.source_uuid }}');"> <span class="icon" data-tooltip="Copy to clipboard"> <i class="fa-solid fa-copy" aria-hidden="true"></i> </span> </a> </td> - <td>{{ item.account }}</td> - <td>{{ item.source_name }}</td> + <td>{{ item.chat.account }}</td> + <td>{{ item.chat.source_name }}</td> + <td>{{ item.person_name|default:"-" }}</td> <td> <div class="buttons"> <button @@ -37,7 +39,7 @@ hx-trigger="click" hx-target="#modals-here" hx-swap="innerHTML" - hx-confirm="Are you sure you wish to unlink {{ item }}?" + hx-confirm="Are you sure you wish to unlink {{ item.chat }}?" class="button"> <span class="icon-text"> <span class="icon"> @@ -46,51 +48,67 @@ </span> </button> {% if type == 'page' %} - <a href="{# url 'signal_contacts' type=type pk=item #}"><button - class="button"> - <span class="icon-text"> - <span class="icon"> - <i class="fa-solid fa-eye"></i> + {% if item.can_compose %} + <a href="{{ item.compose_page_url }}"><button + class="button" + title="Manual text mode"> + <span class="icon-text"> + <span class="icon"> + <i class="{{ item.manual_icon_class }}"></i> + </span> </span> - </span> - </button> - </a> - <a href="{# url 'signal_chats' type=type pk=item #}"><button - class="button"> + </button> + </a> + {% else %} + <button class="button" disabled title="No identifier available for manual send"> + <span class="icon-text"> + <span class="icon"> + <i class="{{ item.manual_icon_class }}"></i> + </span> + </span> + </button> + {% endif %} + <a href="{{ item.ai_url }}"><button + class="button" + title="Open AI workspace"> <span class="icon-text"> <span class="icon"> - <i class="fa-solid fa-envelope"></i> + <i class="fa-solid fa-brain-circuit"></i> </span> </span> </button> </a> {% else %} - <button - hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' - hx-get="{# url 'signal_contacts' type=type pk=item #}" - hx-trigger="click" - hx-target="#{{ type }}s-here" - hx-swap="innerHTML" - class="button"> + {% if item.can_compose %} + <button + hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' + hx-get="{{ item.compose_widget_url }}" + hx-trigger="click" + hx-target="#widgets-here" + hx-swap="afterend" + class="button"> + <span class="icon-text"> + <span class="icon"> + <i class="{{ item.manual_icon_class }}"></i> + </span> + </span> + </button> + {% else %} + <button class="button" disabled title="No identifier available for manual send"> + <span class="icon-text"> + <span class="icon"> + <i class="{{ item.manual_icon_class }}"></i> + </span> + </span> + </button> + {% endif %} + <a href="{{ item.ai_url }}"><button class="button"> <span class="icon-text"> <span class="icon"> - <i class="fa-solid fa-eye"></i> + <i class="fa-solid fa-brain-circuit"></i> </span> </span> - </button> - <button - hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' - hx-get="{# url 'signal_chats' type=type pk=item #}" - hx-trigger="click" - hx-target="#{{ type }}s-here" - hx-swap="innerHTML" - class="button"> - <span class="icon-text"> - <span class="icon"> - <i class="fa-solid fa-envelope"></i> - </span> - </span> - </button> + </button></a> {% endif %} </div> </td> @@ -98,4 +116,4 @@ {% endfor %} </table> -{% endcache %} \ No newline at end of file +{% endcache %} diff --git a/core/views/base.py b/core/views/base.py index 3353fc5..94338dd 100644 --- a/core/views/base.py +++ b/core/views/base.py @@ -2,10 +2,8 @@ import logging # import stripe from django.conf import settings -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import JsonResponse -from django.shortcuts import redirect, render -from django.urls import reverse, reverse_lazy +from django.shortcuts import render +from django.urls import reverse_lazy from django.views import View from django.views.generic.edit import CreateView diff --git a/core/views/compose.py b/core/views/compose.py new file mode 100644 index 0000000..13ec712 --- /dev/null +++ b/core/views/compose.py @@ -0,0 +1,369 @@ +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 diff --git a/core/views/instagram.py b/core/views/instagram.py new file mode 100644 index 0000000..f53d8b4 --- /dev/null +++ b/core/views/instagram.py @@ -0,0 +1,29 @@ +from core.clients import transport +from core.views.signal import Signal, SignalAccountAdd, SignalAccounts + + +class Instagram(Signal): + service = "instagram" + page_title = "Instagram" + accounts_url_name = "instagram_accounts" + + +class InstagramAccounts(SignalAccounts): + service = "instagram" + context_object_name_singular = "Instagram Account" + context_object_name = "Instagram Accounts" + list_url_name = "instagram_accounts" + + def get_queryset(self, **kwargs): + self.extra_context = self._service_context( + service="instagram", + label="Instagram", + add_url_name="instagram_account_add", + show_contact_actions=False, + ) + return self._normalize_accounts(transport.list_accounts("instagram")) + + +class InstagramAccountAdd(SignalAccountAdd): + service = "instagram" + detail_url_name = "instagram_account_add" diff --git a/core/views/queues.py b/core/views/queues.py index 6a34c56..7107c33 100644 --- a/core/views/queues.py +++ b/core/views/queues.py @@ -2,11 +2,11 @@ from asgiref.sync import async_to_sync from django.contrib.auth.mixins import LoginRequiredMixin from django.db import transaction from django.http import HttpResponse +from django.utils import timezone as dj_timezone from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate from rest_framework import status from rest_framework.views import APIView -from core.clients import signalapi from core.forms import QueueForm from core.models import Message, QueuedMessage from core.util import logs @@ -28,22 +28,18 @@ class AcceptMessageAPI(LoginRequiredMixin, APIView): except QueuedMessage.DoesNotExist: return HttpResponse(status=status.HTTP_404_NOT_FOUND) - if queued.session.identifier.service != "signal": - log.warning( - "Queue accept failed: unsupported service '%s' for queued message %s", - queued.session.identifier.service, - queued.id, - ) - return HttpResponse(status=status.HTTP_400_BAD_REQUEST) - - ts = async_to_sync(signalapi.send_message_raw)( - queued.session.identifier.identifier, + ts = async_to_sync(queued.session.identifier.send)( queued.text or "", [], ) if not ts: log.error("Queue accept send failed for queued message %s", queued.id) return HttpResponse(status=status.HTTP_502_BAD_GATEWAY) + sent_ts = ( + int(ts) + if (ts is not None and not isinstance(ts, bool)) + else int(dj_timezone.now().timestamp() * 1000) + ) with transaction.atomic(): Message.objects.create( @@ -51,7 +47,9 @@ class AcceptMessageAPI(LoginRequiredMixin, APIView): session=queued.session, custom_author=queued.custom_author or "BOT", text=queued.text, - ts=ts, + ts=sent_ts, + delivered_ts=sent_ts, + read_source_service=queued.session.identifier.service, ) queued.delete() diff --git a/core/views/signal.py b/core/views/signal.py index f9c9018..b1f0fb1 100644 --- a/core/views/signal.py +++ b/core/views/signal.py @@ -1,12 +1,13 @@ -import base64 - import orjson import requests +from django.conf import settings from django.shortcuts import render +from django.urls import reverse from django.views import View from mixins.views import ObjectList, ObjectRead -from core.models import Chat +from core.clients import transport +from core.models import Chat, PersonIdentifier from core.views.manage.permissions import SuperUserRequiredMixin @@ -18,13 +19,25 @@ class CustomObjectRead(ObjectRead): class Signal(SuperUserRequiredMixin, View): template_name = "pages/signal.html" + service = "signal" + page_title = "Signal" + accounts_url_name = "signal_accounts" def get(self, request): - return render(request, self.template_name) + return render( + request, + self.template_name, + { + "service": self.service, + "service_label": self.page_title, + "accounts_url_name": self.accounts_url_name, + }, + ) class SignalAccounts(SuperUserRequiredMixin, ObjectList): list_template = "partials/signal-accounts.html" + service = "signal" context_object_name_singular = "Signal Account" context_object_name = "Signal Accounts" @@ -32,13 +45,44 @@ class SignalAccounts(SuperUserRequiredMixin, ObjectList): list_url_name = "signal_accounts" list_url_args = ["type"] - def get_queryset(self, **kwargs): - # url = signal:8080/v1/accounts - url = f"http://signal:8080/v1/accounts" - response = requests.get(url) - accounts = orjson.loads(response.text) + def _normalize_accounts(self, rows): + out = [] + for item in rows or []: + if isinstance(item, dict): + value = ( + item.get("number") + or item.get("id") + or item.get("jid") + or item.get("account") + ) + if value: + out.append(str(value)) + elif item: + out.append(str(item)) + return out - return accounts + def _service_context(self, service, label, add_url_name, show_contact_actions): + return { + "service": service, + "service_label": label, + "account_add_url_name": add_url_name, + "show_contact_actions": show_contact_actions, + "endpoint_base": str( + getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080") + ).rstrip("/") + if service == "signal" + else "", + "service_warning": transport.get_service_warning(service), + } + + def get_queryset(self, **kwargs): + self.extra_context = self._service_context( + service="signal", + label="Signal", + add_url_name="signal_account_add", + show_contact_actions=True, + ) + return self._normalize_accounts(transport.list_accounts("signal")) class SignalContactsList(SuperUserRequiredMixin, ObjectList): @@ -55,13 +99,16 @@ class SignalContactsList(SuperUserRequiredMixin, ObjectList): # /v1/configuration/{number}/settings # /v1/identities/{number} # /v1/contacts/{number} - # response = requests.get(f"http://signal:8080/v1/configuration/{self.kwargs['pk']}/settings") + # response = requests.get( + # f"http://signal:8080/v1/configuration/{self.kwargs['pk']}/settings" + # ) # config = orjson.loads(response.text) - response = requests.get(f"http://signal:8080/v1/identities/{self.kwargs['pk']}") + base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/") + response = requests.get(f"{base}/v1/identities/{self.kwargs['pk']}") identities = orjson.loads(response.text) - response = requests.get(f"http://signal:8080/v1/contacts/{self.kwargs['pk']}") + response = requests.get(f"{base}/v1/contacts/{self.kwargs['pk']}") contacts = orjson.loads(response.text) # add identities to contacts @@ -90,8 +137,59 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList): def get_queryset(self, *args, **kwargs): pk = self.kwargs.get("pk", "") - object_list = Chat.objects.filter(account=pk) - return object_list + chats = list(Chat.objects.filter(account=pk)) + rows = [] + for chat in chats: + identifier_candidates = [ + str(chat.source_uuid or "").strip(), + str(chat.source_number or "").strip(), + ] + identifier_candidates = [value for value in identifier_candidates if value] + person_identifier = None + if identifier_candidates: + person_identifier = ( + PersonIdentifier.objects.filter( + user=self.request.user, + service="signal", + identifier__in=identifier_candidates, + ) + .select_related("person") + .first() + ) + + identifier_value = ( + person_identifier.identifier if person_identifier else "" + ) or (chat.source_uuid or chat.source_number or "") + service = "signal" + compose_page_url = "" + compose_widget_url = "" + if identifier_value: + query = f"service={service}&identifier={identifier_value}" + if person_identifier: + query += f"&person={person_identifier.person_id}" + compose_page_url = f"{reverse('compose_page')}?{query}" + compose_widget_url = f"{reverse('compose_widget')}?{query}" + if person_identifier: + ai_url = ( + f"{reverse('ai_workspace')}?person={person_identifier.person_id}" + ) + else: + ai_url = reverse("ai_workspace") + + rows.append( + { + "chat": chat, + "compose_page_url": compose_page_url, + "compose_widget_url": compose_widget_url, + "ai_url": ai_url, + "person_name": ( + person_identifier.person.name if person_identifier else "" + ), + "manual_icon_class": "fa-solid fa-paper-plane", + "can_compose": bool(compose_page_url), + } + ) + return rows class SignalMessagesList(SuperUserRequiredMixin, ObjectList): @@ -100,6 +198,7 @@ class SignalMessagesList(SuperUserRequiredMixin, ObjectList): class SignalAccountAdd(SuperUserRequiredMixin, CustomObjectRead): detail_template = "partials/signal-account-add.html" + service = "signal" context_object_name_singular = "Add Account" context_object_name = "Add Account" @@ -112,9 +211,7 @@ class SignalAccountAdd(SuperUserRequiredMixin, CustomObjectRead): def get_object(self, **kwargs): form_args = self.request.POST.dict() device_name = form_args["device"] - url = f"http://signal:8080/v1/qrcodelink?device_name={device_name}" - response = requests.get(url) - image_bytes = response.content - base64_image = base64.b64encode(image_bytes).decode("utf-8") + image_bytes = transport.get_link_qr(self.service, device_name) + base64_image = transport.image_bytes_to_base64(image_bytes) return base64_image diff --git a/core/views/whatsapp.py b/core/views/whatsapp.py new file mode 100644 index 0000000..bce5eb9 --- /dev/null +++ b/core/views/whatsapp.py @@ -0,0 +1,29 @@ +from core.clients import transport +from core.views.signal import Signal, SignalAccountAdd, SignalAccounts + + +class WhatsApp(Signal): + service = "whatsapp" + page_title = "WhatsApp" + accounts_url_name = "whatsapp_accounts" + + +class WhatsAppAccounts(SignalAccounts): + service = "whatsapp" + context_object_name_singular = "WhatsApp Account" + context_object_name = "WhatsApp Accounts" + list_url_name = "whatsapp_accounts" + + def get_queryset(self, **kwargs): + self.extra_context = self._service_context( + service="whatsapp", + label="WhatsApp", + add_url_name="whatsapp_account_add", + show_contact_actions=False, + ) + return self._normalize_accounts(transport.list_accounts("whatsapp")) + + +class WhatsAppAccountAdd(SignalAccountAdd): + service = "whatsapp" + detail_url_name = "whatsapp_account_add" diff --git a/core/views/workspace.py b/core/views/workspace.py index 3629231..ddff7b0 100644 --- a/core/views/workspace.py +++ b/core/views/workspace.py @@ -1,11 +1,14 @@ -from datetime import datetime, timezone import json import re +import statistics +from datetime import datetime, 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 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 @@ -17,10 +20,11 @@ from core.models import ( AI, AIRequest, AIResult, + AIResultSignal, ChatSession, + Manipulation, Message, MessageEvent, - Manipulation, PatternArtifactExport, PatternMitigationAutoSettings, PatternMitigationCorrection, @@ -32,6 +36,7 @@ from core.models import ( PersonIdentifier, QueuedMessage, WorkspaceConversation, + WorkspaceMetricSnapshot, ) SEND_ENABLED_MODES = {"active", "instant"} @@ -50,6 +55,287 @@ MITIGATION_TABS = { "auto", } +INSIGHT_GROUPS = { + "identity": { + "title": "Conversation Identity", + "summary": ( + "Describes where this workspace data comes from and which thread is " + "being analysed." + ), + }, + "stability": { + "title": "Stability Scoring", + "summary": ( + "Stability combines reciprocity, continuity, response pace, and " + "message-volatility regularity into a 0-100 score." + ), + }, + "confidence": { + "title": "Sample And Confidence", + "summary": ( + "Confidence scales with data volume, day coverage, and observed " + "response pairs so sparse windows do not overclaim certainty." + ), + }, + "commitment": { + "title": "Commitment Directionality", + "summary": ( + "Commitment estimates effort in each direction by combining response " + "speed with participation balance." + ), + }, + "timeline": { + "title": "Recency And Cadence", + "summary": ( + "Tracks when messages and AI analysis most recently occurred to " + "separate stale from current assessments." + ), + }, +} + +INSIGHT_METRICS = { + "platform": { + "title": "Platform", + "group": "identity", + "history_field": None, + "calculation": ( + "Updated to the service of the latest message in the sampled history " + "window." + ), + "psychology": ( + "Platform shifts can indicate context switches in how the relationship " + "is maintained across channels." + ), + }, + "thread": { + "title": "Thread", + "group": "identity", + "history_field": None, + "calculation": ( + "Primary thread identifier used to anchor this workspace conversation." + ), + "psychology": ( + "A stable thread usually implies continuity; frequent thread resets can " + "reflect fragmentation." + ), + }, + "workspace_created": { + "title": "Workspace Created", + "group": "identity", + "history_field": None, + "calculation": "Creation timestamp of this workspace conversation record.", + "psychology": ( + "Older workspaces allow stronger trend reading because they include " + "longer temporal context." + ), + }, + "stability_state": { + "title": "Stability State", + "group": "stability", + "history_field": None, + "calculation": ( + "Derived from stability score bands: Stable >= 70, Watch >= 50, " + "Fragile < 50, or Calibrating if data is insufficient." + ), + "psychology": ( + "Use state as a risk band, not a diagnosis. It indicates likely " + "interaction friction level." + ), + }, + "stability_score": { + "title": "Stability Score", + "group": "stability", + "history_field": "stability_score", + "calculation": ( + "0.35*reciprocity + 0.25*continuity + 0.20*response + " + "0.20*volatility." + ), + "psychology": ( + "Higher values suggest consistent mutual engagement patterns; falling " + "values often precede misunderstandings or withdrawal cycles." + ), + }, + "stability_confidence": { + "title": "Stability Confidence", + "group": "confidence", + "history_field": "stability_confidence", + "calculation": ( + "0.50*message_volume + 0.30*day_coverage + 0.20*response_pair_density." + ), + "psychology": ( + "Low confidence means defer strong conclusions; treat outputs as " + "tentative signals." + ), + }, + "sample_messages": { + "title": "Sample Messages", + "group": "confidence", + "history_field": "stability_sample_messages", + "calculation": "Count of messages in the analysed window.", + "psychology": ( + "Larger sample size improves reliability and reduces overreaction to " + "single events." + ), + }, + "sample_days": { + "title": "Sample Days", + "group": "confidence", + "history_field": "stability_sample_days", + "calculation": "Count of distinct calendar days represented in the sample.", + "psychology": ( + "Coverage across days better captures rhythm, not just intensity " + "bursts." + ), + }, + "stability_computed": { + "title": "Stability Computed", + "group": "stability", + "history_field": None, + "calculation": "Timestamp of the latest stability computation run.", + "psychology": ( + "Recent recomputation indicates current context; stale computations may " + "lag emotional reality." + ), + }, + "commitment_inbound": { + "title": "Commit In", + "group": "commitment", + "history_field": "commitment_inbound_score", + "calculation": ( + "0.60*inbound_response_score + 0.40*inbound_balance_score." + ), + "psychology": ( + "Estimates counterpart follow-through and reciprocity toward the user." + ), + }, + "commitment_outbound": { + "title": "Commit Out", + "group": "commitment", + "history_field": "commitment_outbound_score", + "calculation": ( + "0.60*outbound_response_score + 0.40*outbound_balance_score." + ), + "psychology": ( + "Estimates user follow-through and consistency toward the counterpart." + ), + }, + "commitment_confidence": { + "title": "Commit Confidence", + "group": "confidence", + "history_field": "commitment_confidence", + "calculation": ( + "Uses the same confidence weighting as stability for directional scores." + ), + "psychology": ( + "Low confidence means directionality gaps might be temporary rather " + "than structural." + ), + }, + "commitment_computed": { + "title": "Commitment Computed", + "group": "commitment", + "history_field": None, + "calculation": "Timestamp of the latest commitment computation run.", + "psychology": ( + "Recency matters because commitment signals shift quickly under stress." + ), + }, + "last_event": { + "title": "Last Event", + "group": "timeline", + "history_field": "source_event_ts", + "calculation": "Unix ms timestamp of the newest message in this workspace.", + "psychology": ( + "Long inactivity windows can indicate pause, repair distance, or " + "channel migration." + ), + }, + "last_ai_run": { + "title": "Last AI Run", + "group": "timeline", + "history_field": None, + "calculation": "Most recent completed AI analysis timestamp.", + "psychology": ( + "Recency of AI summaries affects relevance of suggested interventions." + ), + }, +} + +INSIGHT_GRAPH_SPECS = [ + { + "slug": "stability_score", + "title": "Stability Score", + "field": "stability_score", + "group": "stability", + "y_min": 0, + "y_max": 100, + }, + { + "slug": "stability_confidence", + "title": "Stability Confidence", + "field": "stability_confidence", + "group": "confidence", + "y_min": 0, + "y_max": 1, + }, + { + "slug": "sample_messages", + "title": "Sample Messages", + "field": "stability_sample_messages", + "group": "confidence", + "y_min": 0, + "y_max": None, + }, + { + "slug": "sample_days", + "title": "Sample Days", + "field": "stability_sample_days", + "group": "confidence", + "y_min": 0, + "y_max": None, + }, + { + "slug": "commitment_inbound", + "title": "Commit In", + "field": "commitment_inbound_score", + "group": "commitment", + "y_min": 0, + "y_max": 100, + }, + { + "slug": "commitment_outbound", + "title": "Commit Out", + "field": "commitment_outbound_score", + "group": "commitment", + "y_min": 0, + "y_max": 100, + }, + { + "slug": "commitment_confidence", + "title": "Commit Confidence", + "field": "commitment_confidence", + "group": "confidence", + "y_min": 0, + "y_max": 1, + }, + { + "slug": "inbound_messages", + "title": "Inbound Messages", + "field": "inbound_messages", + "group": "timeline", + "y_min": 0, + "y_max": None, + }, + { + "slug": "outbound_messages", + "title": "Outbound Messages", + "field": "outbound_messages", + "group": "timeline", + "y_min": 0, + "y_max": None, + }, +] + def _format_unix_ms(ts): if not ts: @@ -124,19 +410,64 @@ def _get_queue_manipulation(user, person): return matched.filter(mode__in=SEND_ENABLED_MODES).first() or matched.first() -def _resolve_person_identifier(user, person): +def _resolve_person_identifier(user, person, preferred_service=None): """ Resolve the best identifier for outbound share/send operations. - Prefer Signal identifier, then fallback to any identifier. + Prefer `preferred_service` when provided, then Signal, then any identifier. """ - return ( - PersonIdentifier.objects.filter( + if preferred_service: + preferred = PersonIdentifier.objects.filter( user=user, person=person, - service="signal", + service=preferred_service, ).first() - or PersonIdentifier.objects.filter(user=user, person=person).first() + if preferred: + return preferred + signal_row = PersonIdentifier.objects.filter( + user=user, + person=person, + service="signal", + ).first() + if signal_row: + return signal_row + return PersonIdentifier.objects.filter(user=user, person=person).first() + + +def _preferred_service_for_person(user, person): + """ + Best-effort service hint from the most recent workspace conversation. + """ + conversation = ( + WorkspaceConversation.objects.filter( + user=user, + participants=person, + ) + .exclude(platform_type="") + .order_by("-last_event_ts", "-created_at") + .first() ) + if conversation and conversation.platform_type: + return conversation.platform_type + return None + + +def _compose_page_url_for_person(user, person): + preferred_service = _preferred_service_for_person(user, person) + identifier_row = _resolve_person_identifier( + user=user, + person=person, + preferred_service=preferred_service, + ) + if identifier_row is None: + return "" + query = urlencode( + { + "service": identifier_row.service, + "identifier": identifier_row.identifier, + "person": str(person.id), + } + ) + return f"{reverse('compose_page')}?{query}" def _is_truthy(value): @@ -150,6 +481,146 @@ def _sanitize_active_tab(value, default="plan_board"): return default +def _to_float(value): + if value is None: + return None + return float(value) + + +def _format_metric_value(conversation, metric_slug): + if metric_slug == "platform": + return conversation.get_platform_type_display() or "-" + if metric_slug == "thread": + return conversation.platform_thread_id or "-" + if metric_slug == "workspace_created": + return conversation.created_at + if metric_slug == "stability_state": + return conversation.get_stability_state_display() + if metric_slug == "stability_score": + return conversation.stability_score + if metric_slug == "stability_confidence": + return conversation.stability_confidence + if metric_slug == "sample_messages": + return conversation.stability_sample_messages + if metric_slug == "sample_days": + return conversation.stability_sample_days + if metric_slug == "stability_computed": + return conversation.stability_last_computed_at + if metric_slug == "commitment_inbound": + return conversation.commitment_inbound_score + if metric_slug == "commitment_outbound": + return conversation.commitment_outbound_score + if metric_slug == "commitment_confidence": + return conversation.commitment_confidence + if metric_slug == "commitment_computed": + return conversation.commitment_last_computed_at + if metric_slug == "last_event": + return _format_unix_ms(conversation.last_event_ts) or "-" + if metric_slug == "last_ai_run": + return conversation.last_ai_run_at + return "-" + + +def _metric_psychological_read(metric_slug, conversation): + if metric_slug == "stability_score": + score = conversation.stability_score + if score is None: + return "Calibrating: collect more interaction data before interpreting." + if score >= 70: + return "Pattern suggests low relational friction and resilient repair cycles." + if score >= 50: + return "Pattern suggests moderate strain; monitor for repeated escalation loops." + return "Pattern suggests high friction risk; prioritise safety and repair pacing." + if metric_slug == "stability_confidence": + conf = conversation.stability_confidence or 0.0 + if conf < 0.25: + return "Low certainty: treat this as a weak signal, not a conclusion." + if conf < 0.6: + return "Moderate certainty: useful directional cue, still context-dependent." + return "High certainty: trend interpretation is likely reliable." + if metric_slug in {"commitment_inbound", "commitment_outbound"}: + inbound = conversation.commitment_inbound_score + outbound = conversation.commitment_outbound_score + if inbound is None or outbound is None: + return "Calibrating: directional commitment cannot be inferred yet." + gap = abs(inbound - outbound) + if gap < 10: + return "Directional effort appears balanced." + if inbound > outbound: + return "Counterpart appears more responsive than user in this sample." + return "User appears more responsive than counterpart in this sample." + if metric_slug == "sample_days": + days = conversation.stability_sample_days or 0 + if days < 3: + return "Coverage is narrow; day-to-day rhythm inference is weak." + return "Coverage spans multiple days, improving cadence interpretation." + return "" + + +def _history_points(conversation, field_name): + rows = ( + conversation.metric_snapshots.exclude(**{f"{field_name}__isnull": True}) + .order_by("computed_at") + .values("computed_at", field_name) + ) + points = [] + for row in rows: + points.append( + { + "x": row["computed_at"].isoformat(), + "y": row[field_name], + } + ) + return points + + +def _all_graph_payload(conversation): + graphs = [] + for spec in INSIGHT_GRAPH_SPECS: + points = _history_points(conversation, spec["field"]) + graphs.append( + { + "slug": spec["slug"], + "title": spec["title"], + "group": spec["group"], + "group_title": INSIGHT_GROUPS[spec["group"]]["title"], + "points": points, + "count": len(points), + "y_min": spec["y_min"], + "y_max": spec["y_max"], + } + ) + return graphs + + +def _store_metric_snapshot(conversation, payload): + compare_keys = [ + "source_event_ts", + "stability_state", + "stability_score", + "stability_confidence", + "stability_sample_messages", + "stability_sample_days", + "commitment_inbound_score", + "commitment_outbound_score", + "commitment_confidence", + "inbound_messages", + "outbound_messages", + "reciprocity_score", + "continuity_score", + "response_score", + "volatility_score", + "inbound_response_score", + "outbound_response_score", + "balance_inbound_score", + "balance_outbound_score", + ] + last = conversation.metric_snapshots.first() + if last and all(getattr(last, key) == payload.get(key) for key in compare_keys): + return + WorkspaceMetricSnapshot.objects.create(conversation=conversation, **payload) + + def _parse_draft_options(result_text): """ Parse model output into labeled draft options shown simultaneously in UI. @@ -161,7 +632,10 @@ def _parse_draft_options(result_text): def clean_option_text(value): value = (value or "").strip(" \n\r\t*:") # Strip surrounding quotes when the whole option is wrapped. - if len(value) >= 2 and ((value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'")): + if len(value) >= 2 and ( + (value[0] == '"' and value[-1] == '"') + or (value[0] == "'" and value[-1] == "'") + ): value = value[1:-1].strip() return value @@ -215,9 +689,13 @@ def _parse_draft_options(result_text): # Secondary parser: Option 1/2/3 blocks. option_split_re = re.compile(r"(?im)^\s*Option\s+\d+\s*$") - chunks = [chunk.strip() for chunk in option_split_re.split(content) if chunk.strip()] + chunks = [ + chunk.strip() for chunk in option_split_re.split(content) if chunk.strip() + ] parsed = [] - prefix_re = re.compile(r"(?im)^(?:\*\*)?\s*(Soft|Neutral|Firm)\s*(?:Tone|Response|Reply)?\s*:?\s*(?:\*\*)?\s*") + prefix_re = re.compile( + r"(?im)^(?:\*\*)?\s*(Soft|Neutral|Firm)\s*(?:Tone|Response|Reply)?\s*:?\s*(?:\*\*)?\s*" + ) for idx, chunk in enumerate(chunks, start=1): label = f"Option {idx}" prefix_match = prefix_re.match(chunk) @@ -230,11 +708,15 @@ def _parse_draft_options(result_text): return dedupe_by_label(parsed)[:3] # Final fallback: use first non-empty paragraphs. - paragraphs = [para.strip() for para in re.split(r"\n\s*\n", content) if para.strip()] - return dedupe_by_label([ - {"label": f"Option {idx}", "text": para} - for idx, para in enumerate(paragraphs[:3], start=1) - ]) + paragraphs = [ + para.strip() for para in re.split(r"\n\s*\n", content) if para.strip() + ] + return dedupe_by_label( + [ + {"label": f"Option {idx}", "text": para} + for idx, para in enumerate(paragraphs[:3], start=1) + ] + ) def _extract_seed_entities_from_context(raw_context): @@ -335,7 +817,11 @@ def _extract_seed_entities_from_context(raw_context): if in_quick_cheat: quick_line = re.sub(r"^\s*(?:[-*]|\d+\.)\s*", "", line).strip() - if quick_line and len(quick_line) <= 120 and not quick_line.lower().startswith("if you want"): + if ( + quick_line + and len(quick_line) <= 120 + and not quick_line.lower().startswith("if you want") + ): fundamentals.append(quick_line) continue @@ -389,7 +875,9 @@ def _merge_seed_entities(artifacts, seed): seed = seed or {} fundamentals = list(merged.get("fundamental_items") or []) - fundamentals = list(dict.fromkeys(fundamentals + list(seed.get("fundamentals") or []))) + fundamentals = list( + dict.fromkeys(fundamentals + list(seed.get("fundamentals") or [])) + ) merged["fundamental_items"] = fundamentals def merge_artifact_list(existing, injected, body_key): @@ -408,8 +896,12 @@ def _merge_seed_entities(artifacts, seed): seen.add(key) return existing - merged["rules"] = merge_artifact_list(merged.get("rules"), seed.get("rules"), "content") - merged["games"] = merge_artifact_list(merged.get("games"), seed.get("games"), "instructions") + merged["rules"] = merge_artifact_list( + merged.get("rules"), seed.get("rules"), "content" + ) + merged["games"] = merge_artifact_list( + merged.get("games"), seed.get("games"), "instructions" + ) return merged @@ -509,7 +1001,145 @@ def _parse_result_sections(result_text): return cleaned fallback = _clean_inline_markdown(result_text or "") - return [{"title": "Output", "level": 3, "blocks": [{"type": "p", "items": [fallback]}]}] + return [ + {"title": "Output", "level": 3, "blocks": [{"type": "p", "items": [fallback]}]} + ] + + +def _build_interaction_signals(operation, result_text, message_event_ids): + text = (result_text or "").lower() + signals = [] + heuristics = [ + ("repair", "repair", "positive"), + ("de-escalation", "de-escalat", "positive"), + ("open loop", "open loop", "neutral"), + ("risk", "risk", "risk"), + ("conflict", "conflict", "risk"), + ] + for label, token, valence in heuristics: + if token in text: + signals.append( + { + "label": label, + "valence": valence, + "message_event_ids": message_event_ids[:6], + } + ) + if not signals and operation == "draft_reply": + signals.append( + { + "label": "draft_generated", + "valence": "positive", + "message_event_ids": message_event_ids[:3], + } + ) + return signals[:8] + + +def _memory_kind_from_title(title): + lowered = str(title or "").strip().lower() + if "open" in lowered and "loop" in lowered: + return "open_loops", "Open Loops" + if "emotion" in lowered or "state" in lowered: + return "emotional_state", "Emotional State" + if "pattern" in lowered: + return "patterns", "Patterns" + if "friction" in lowered: + return "friction_loops", "Friction Loops" + if "rule" in lowered or "next-step" in lowered: + return "rules", "Rules" + if "summary" in lowered or "key" in lowered: + return "summary", "Summary" + return "insights", "Insights" + + +def _build_memory_proposals(operation, result_text): + """ + Build structured, grouped proposals from model output sections. + """ + if operation not in {"summarise", "extract_patterns"}: + return [] + + sections = _parse_result_sections(result_text) + proposals = [] + for section in sections: + kind, label = _memory_kind_from_title(section.get("title")) + section_title = (section.get("title") or "").strip() + for block in section.get("blocks", []): + for item in block.get("items", []): + content = str(item or "").strip() + if not content: + continue + proposals.append( + { + "kind": kind, + "kind_label": label, + "section_title": section_title, + "content": content, + "status": "proposed", + } + ) + if not proposals: + fallback = (result_text or "").strip() + if fallback: + proposals.append( + { + "kind": "summary", + "kind_label": "Summary", + "section_title": "Summary", + "content": fallback, + "status": "proposed", + } + ) + return proposals[:80] + + +def _group_memory_proposals(memory_proposals): + grouped = {} + for item in memory_proposals or []: + label = str(item.get("kind_label") or item.get("kind") or "Insights").strip() + key = label.lower() + if key not in grouped: + grouped[key] = {"title": label, "items": []} + grouped[key]["items"].append(item) + return list(grouped.values()) + + +def _window_spec_tags(window_spec): + if not isinstance(window_spec, dict): + return [] + out = [] + if "limit" in window_spec: + out.append(f"Window: last {window_spec.get('limit')} messages") + if "since_ts" in window_spec: + out.append(f"Since: {_format_unix_ms(window_spec.get('since_ts'))}") + if "between_ts" in window_spec and isinstance(window_spec.get("between_ts"), list): + values = window_spec.get("between_ts") + if len(values) == 2: + out.append( + f"Range: {_format_unix_ms(values[0])} to {_format_unix_ms(values[1])}" + ) + if not out: + for key, value in window_spec.items(): + out.append(f"{str(key).replace('_', ' ').title()}: {value}") + return out[:6] + + +def _policy_snapshot_tags(policy_snapshot): + if not isinstance(policy_snapshot, dict): + return [] + out = [] + send_state = policy_snapshot.get("send_state") + if isinstance(send_state, dict): + out.append(f"Send: {'enabled' if send_state.get('can_send') else 'blocked'}") + text = str(send_state.get("text") or "").strip() + if text: + out.append(text) + for key, value in policy_snapshot.items(): + if key == "send_state": + continue + out.append(f"{str(key).replace('_', ' ').title()}: {value}") + return out[:6] def _extract_json_object(raw): @@ -624,7 +1254,15 @@ def _default_artifacts_from_patterns(result_text, person, output_profile="framew } -def _build_mitigation_artifacts(ai_obj, person, source_text, creation_mode, inspiration, fundamentals, output_profile): +def _build_mitigation_artifacts( + ai_obj, + person, + source_text, + creation_mode, + inspiration, + fundamentals, + output_profile, +): fallback = _default_artifacts_from_patterns(source_text, person, output_profile) if not ai_obj: @@ -669,7 +1307,9 @@ def _build_mitigation_artifacts(ai_obj, person, source_text, creation_mode, insp parsed_fundamentals = parsed.get("fundamental_items") if isinstance(parsed_fundamentals, list): - merged_fundamentals = [str(item).strip() for item in parsed_fundamentals if str(item).strip()] + merged_fundamentals = [ + str(item).strip() for item in parsed_fundamentals if str(item).strip() + ] else: merged_fundamentals = [] if fundamentals: @@ -714,9 +1354,17 @@ def _build_mitigation_artifacts(ai_obj, person, source_text, creation_mode, insp def _serialize_export_payload(plan, artifact_type, export_format): - rules = list(plan.rules.order_by("created_at").values("title", "content", "enabled")) - games = list(plan.games.order_by("created_at").values("title", "instructions", "enabled")) - corrections = list(plan.corrections.order_by("created_at").values("title", "clarification", "enabled")) + rules = list( + plan.rules.order_by("created_at").values("title", "content", "enabled") + ) + games = list( + plan.games.order_by("created_at").values("title", "instructions", "enabled") + ) + corrections = list( + plan.corrections.order_by("created_at").values( + "title", "clarification", "enabled" + ) + ) body = { "protocol_version": "artifact-v1", @@ -791,7 +1439,9 @@ def _serialize_export_payload(plan, artifact_type, export_format): lines.append("## Corrections") if corrections: for idx, correction in enumerate(corrections, start=1): - lines.append(f"{idx}. **{correction['title']}** - {correction['clarification']}") + lines.append( + f"{idx}. **{correction['title']}** - {correction['clarification']}" + ) else: lines.append("- (none)") @@ -807,16 +1457,373 @@ def _serialize_export_payload(plan, artifact_type, export_format): def _conversation_for_person(user, person): - conversation, _ = WorkspaceConversation.objects.get_or_create( - user=user, - platform_type="signal", - title=f"{person.name} Workspace", - defaults={"platform_thread_id": str(person.id)}, + primary_identifier = ( + PersonIdentifier.objects.filter(user=user, person=person) + .order_by("service") + .first() ) + default_platform = primary_identifier.service if primary_identifier else "signal" + thread_id = str(person.id) + + conversation = ( + WorkspaceConversation.objects.filter( + user=user, + platform_thread_id=thread_id, + ).first() + or WorkspaceConversation.objects.filter( + user=user, + participants=person, + ) + .order_by("-created_at") + .first() + ) + if conversation is None: + conversation = WorkspaceConversation.objects.create( + user=user, + platform_type=default_platform, + title=f"{person.name} Workspace", + platform_thread_id=thread_id, + ) + else: + update_fields = [] + if conversation.platform_thread_id != thread_id: + conversation.platform_thread_id = thread_id + update_fields.append("platform_thread_id") + expected_title = f"{person.name} Workspace" + if not conversation.title: + conversation.title = expected_title + update_fields.append("title") + if update_fields: + conversation.save(update_fields=update_fields) + conversation.participants.add(person) + _refresh_conversation_stability(conversation, user, person) return conversation +def _score_from_lag(lag_ms, target_hours=4): + if lag_ms is None: + return 50.0 + target_ms = max(1, target_hours) * 60 * 60 * 1000 + return max(0.0, min(100.0, 100.0 / (1.0 + (lag_ms / target_ms)))) + + +def _median_or_none(values): + if not values: + return None + return float(statistics.median(values)) + + +def _refresh_conversation_stability(conversation, user, person): + """ + Populate stability/commitment fields from cross-platform history. + + Uses Person -> PersonIdentifier -> ChatSession -> Message so all linked + services contribute to one workspace conversation. + """ + now_ts = dj_timezone.now() + + identifiers = list( + PersonIdentifier.objects.filter( + user=user, + person=person, + ) + ) + identifier_values = { + str(row.identifier or "").strip() for row in identifiers if row.identifier + } + if not identifiers: + conversation.stability_state = WorkspaceConversation.StabilityState.CALIBRATING + conversation.stability_score = None + conversation.stability_confidence = 0.0 + conversation.stability_sample_messages = 0 + conversation.stability_sample_days = 0 + conversation.commitment_inbound_score = None + conversation.commitment_outbound_score = None + conversation.commitment_confidence = 0.0 + conversation.stability_last_computed_at = now_ts + conversation.commitment_last_computed_at = now_ts + conversation.save( + update_fields=[ + "stability_state", + "stability_score", + "stability_confidence", + "stability_sample_messages", + "stability_sample_days", + "commitment_inbound_score", + "commitment_outbound_score", + "commitment_confidence", + "stability_last_computed_at", + "commitment_last_computed_at", + ] + ) + _store_metric_snapshot( + conversation, + { + "source_event_ts": conversation.last_event_ts, + "stability_state": conversation.stability_state, + "stability_score": None, + "stability_confidence": 0.0, + "stability_sample_messages": 0, + "stability_sample_days": 0, + "commitment_inbound_score": None, + "commitment_outbound_score": None, + "commitment_confidence": 0.0, + "inbound_messages": 0, + "outbound_messages": 0, + "reciprocity_score": None, + "continuity_score": None, + "response_score": None, + "volatility_score": None, + "inbound_response_score": None, + "outbound_response_score": None, + "balance_inbound_score": None, + "balance_outbound_score": None, + }, + ) + return + + latest_ts = ( + Message.objects.filter( + user=user, + session__identifier__in=identifiers, + ) + .order_by("-ts") + .values_list("ts", flat=True) + .first() + ) + if ( + conversation.stability_last_computed_at + and latest_ts is not None + and conversation.last_event_ts == latest_ts + and (now_ts - conversation.stability_last_computed_at).total_seconds() < 120 + ): + return + + rows = list( + Message.objects.filter( + user=user, + session__identifier__in=identifiers, + ) + .order_by("ts") + .values("ts", "sender_uuid", "session__identifier__service") + ) + if not rows: + conversation.stability_state = WorkspaceConversation.StabilityState.CALIBRATING + conversation.stability_score = None + conversation.stability_confidence = 0.0 + conversation.stability_sample_messages = 0 + conversation.stability_sample_days = 0 + conversation.last_event_ts = None + conversation.commitment_inbound_score = None + conversation.commitment_outbound_score = None + conversation.commitment_confidence = 0.0 + conversation.stability_last_computed_at = now_ts + conversation.commitment_last_computed_at = now_ts + conversation.save( + update_fields=[ + "stability_state", + "stability_score", + "stability_confidence", + "stability_sample_messages", + "stability_sample_days", + "last_event_ts", + "commitment_inbound_score", + "commitment_outbound_score", + "commitment_confidence", + "stability_last_computed_at", + "commitment_last_computed_at", + ] + ) + _store_metric_snapshot( + conversation, + { + "source_event_ts": conversation.last_event_ts, + "stability_state": conversation.stability_state, + "stability_score": None, + "stability_confidence": 0.0, + "stability_sample_messages": 0, + "stability_sample_days": 0, + "commitment_inbound_score": None, + "commitment_outbound_score": None, + "commitment_confidence": 0.0, + "inbound_messages": 0, + "outbound_messages": 0, + "reciprocity_score": None, + "continuity_score": None, + "response_score": None, + "volatility_score": None, + "inbound_response_score": None, + "outbound_response_score": None, + "balance_inbound_score": None, + "balance_outbound_score": None, + }, + ) + return + + inbound_count = 0 + outbound_count = 0 + daily_counts = {} + inbound_response_lags = [] + outbound_response_lags = [] + pending_in_ts = None + pending_out_ts = None + first_ts = rows[0]["ts"] + last_ts = rows[-1]["ts"] + latest_service = ( + rows[-1].get("session__identifier__service") or conversation.platform_type + ) + + for row in rows: + ts = int(row["ts"] or 0) + sender = str(row.get("sender_uuid") or "").strip() + is_inbound = sender in identifier_values + direction = "in" if is_inbound else "out" + day_key = datetime.fromtimestamp(ts / 1000, tz=timezone.utc).date().isoformat() + daily_counts[day_key] = daily_counts.get(day_key, 0) + 1 + + if direction == "in": + inbound_count += 1 + if pending_out_ts is not None and ts >= pending_out_ts: + inbound_response_lags.append(ts - pending_out_ts) + pending_out_ts = None + pending_in_ts = ts + else: + outbound_count += 1 + if pending_in_ts is not None and ts >= pending_in_ts: + outbound_response_lags.append(ts - pending_in_ts) + pending_in_ts = None + pending_out_ts = ts + + message_count = len(rows) + span_days = max(1, int(((last_ts - first_ts) / (24 * 60 * 60 * 1000)) + 1)) + sample_days = len(daily_counts) + + total_messages = max(1, inbound_count + outbound_count) + reciprocity_score = 100.0 * ( + 1.0 - abs(inbound_count - outbound_count) / total_messages + ) + continuity_score = 100.0 * min(1.0, sample_days / max(1, span_days)) + out_resp_score = _score_from_lag(_median_or_none(outbound_response_lags)) + in_resp_score = _score_from_lag(_median_or_none(inbound_response_lags)) + response_score = (out_resp_score + in_resp_score) / 2.0 + + daily_values = list(daily_counts.values()) + if len(daily_values) > 1: + mean_daily = statistics.mean(daily_values) + stdev_daily = statistics.pstdev(daily_values) + cv = (stdev_daily / mean_daily) if mean_daily else 1.0 + volatility_score = max(0.0, 100.0 * (1.0 - min(cv, 1.5) / 1.5)) + else: + volatility_score = 60.0 + + stability_score = ( + (0.35 * reciprocity_score) + + (0.25 * continuity_score) + + (0.20 * response_score) + + (0.20 * volatility_score) + ) + + balance_out = 100.0 * min(1.0, outbound_count / max(1, inbound_count)) + balance_in = 100.0 * min(1.0, inbound_count / max(1, outbound_count)) + commitment_out = (0.60 * out_resp_score) + (0.40 * balance_out) + commitment_in = (0.60 * in_resp_score) + (0.40 * balance_in) + + msg_conf = min(1.0, message_count / 200.0) + day_conf = min(1.0, sample_days / 30.0) + pair_conf = min( + 1.0, (len(inbound_response_lags) + len(outbound_response_lags)) / 40.0 + ) + confidence = (0.50 * msg_conf) + (0.30 * day_conf) + (0.20 * pair_conf) + + if message_count < 20 or sample_days < 3 or confidence < 0.25: + stability_state = WorkspaceConversation.StabilityState.CALIBRATING + stability_score_value = None + commitment_in_value = None + commitment_out_value = None + else: + stability_score_value = round(stability_score, 2) + commitment_in_value = round(commitment_in, 2) + commitment_out_value = round(commitment_out, 2) + if stability_score_value >= 70: + stability_state = WorkspaceConversation.StabilityState.STABLE + elif stability_score_value >= 50: + stability_state = WorkspaceConversation.StabilityState.WATCH + else: + stability_state = WorkspaceConversation.StabilityState.FRAGILE + + feedback_state = "balanced" + if outbound_count > (inbound_count * 1.5): + feedback_state = "withdrawing" + elif inbound_count > (outbound_count * 1.5): + feedback_state = "overextending" + + feedback = dict(conversation.participant_feedback or {}) + feedback[str(person.id)] = { + "state": feedback_state, + "inbound_messages": inbound_count, + "outbound_messages": outbound_count, + "sample_messages": message_count, + "sample_days": sample_days, + "updated_at": now_ts.isoformat(), + } + + conversation.platform_type = latest_service or conversation.platform_type + conversation.last_event_ts = last_ts + conversation.stability_state = stability_state + conversation.stability_score = stability_score_value + conversation.stability_confidence = round(confidence, 3) + conversation.stability_sample_messages = message_count + conversation.stability_sample_days = sample_days + conversation.stability_last_computed_at = now_ts + conversation.commitment_inbound_score = commitment_in_value + conversation.commitment_outbound_score = commitment_out_value + conversation.commitment_confidence = round(confidence, 3) + conversation.commitment_last_computed_at = now_ts + conversation.participant_feedback = feedback + conversation.save( + update_fields=[ + "platform_type", + "last_event_ts", + "stability_state", + "stability_score", + "stability_confidence", + "stability_sample_messages", + "stability_sample_days", + "stability_last_computed_at", + "commitment_inbound_score", + "commitment_outbound_score", + "commitment_confidence", + "commitment_last_computed_at", + "participant_feedback", + ] + ) + _store_metric_snapshot( + conversation, + { + "source_event_ts": last_ts, + "stability_state": stability_state, + "stability_score": _to_float(stability_score_value), + "stability_confidence": round(confidence, 3), + "stability_sample_messages": message_count, + "stability_sample_days": sample_days, + "commitment_inbound_score": _to_float(commitment_in_value), + "commitment_outbound_score": _to_float(commitment_out_value), + "commitment_confidence": round(confidence, 3), + "inbound_messages": inbound_count, + "outbound_messages": outbound_count, + "reciprocity_score": round(reciprocity_score, 3), + "continuity_score": round(continuity_score, 3), + "response_score": round(response_score, 3), + "volatility_score": round(volatility_score, 3), + "inbound_response_score": round(in_resp_score, 3), + "outbound_response_score": round(out_resp_score, 3), + "balance_inbound_score": round(balance_in, 3), + "balance_outbound_score": round(balance_out, 3), + }, + ) + + def _parse_fundamentals(raw_text): lines = [] for line in (raw_text or "").splitlines(): @@ -890,9 +1897,13 @@ def _build_engage_payload( "game": "Game", "correction": "Correction", }.get(source_kind, (source_kind or "Artifact").title()) - artifact_name_raw = (getattr(source_obj, "title", None) or f"{artifact_type_label} Item").strip() + artifact_name_raw = ( + getattr(source_obj, "title", None) or f"{artifact_type_label} Item" + ).strip() artifact_name = ( - _normalize_correction_title(artifact_name_raw, fallback=f"{artifact_type_label} Item") + _normalize_correction_title( + artifact_name_raw, fallback=f"{artifact_type_label} Item" + ) if source_kind == "correction" else artifact_name_raw ) @@ -1124,7 +2135,10 @@ def _detect_violation_candidates(plan, recent_rows): if not text: continue upper_ratio = ( - (sum(1 for c in text if c.isupper()) / max(1, sum(1 for c in text if c.isalpha()))) + ( + sum(1 for c in text if c.isupper()) + / max(1, sum(1 for c in text if c.isalpha())) + ) if any(c.isalpha() for c in text) else 0 ) @@ -1154,9 +2168,13 @@ def _normalize_violation_items(raw_items): normalized = [] seen = set() for item in raw_items or []: - title = _normalize_correction_title(item.get("title") or "", fallback="Correction") + title = _normalize_correction_title( + item.get("title") or "", fallback="Correction" + ) phrase = str(item.get("source_phrase") or "").strip() - clarification = str(item.get("clarification") or item.get("correction") or "").strip() + clarification = str( + item.get("clarification") or item.get("correction") or "" + ).strip() severity = str(item.get("severity") or "medium").strip().lower() if severity not in {"low", "medium", "high"}: severity = "medium" @@ -1230,7 +2248,9 @@ def _ai_detect_violations(user, plan, person, recent_rows): "source_phrase": correction.source_phrase, "clarification": correction.clarification, } - for correction in plan.corrections.filter(enabled=True).order_by("created_at")[:30] + for correction in plan.corrections.filter(enabled=True).order_by("created_at")[ + :30 + ] ] source_payload = { "person": person.name, @@ -1291,7 +2311,9 @@ def _maybe_send_auto_notification(user, auto_settings, title, body): user.sendmsg(body, title=title) -def _run_auto_analysis_for_plan(user, person, conversation, plan, auto_settings, trigger="manual"): +def _run_auto_analysis_for_plan( + user, person, conversation, plan, auto_settings, trigger="manual" +): if not auto_settings.enabled: return { "ran": False, @@ -1311,7 +2333,11 @@ def _run_auto_analysis_for_plan(user, person, conversation, plan, auto_settings, } now = dj_timezone.now() - if trigger == "auto" and auto_settings.last_run_at and auto_settings.check_cooldown_seconds: + if ( + trigger == "auto" + and auto_settings.last_run_at + and auto_settings.check_cooldown_seconds + ): elapsed = (now - auto_settings.last_run_at).total_seconds() if elapsed < auto_settings.check_cooldown_seconds: return { @@ -1340,9 +2366,13 @@ def _run_auto_analysis_for_plan(user, person, conversation, plan, auto_settings, } ) if not recent_rows: - auto_settings.last_result_summary = "No recent messages available for automation." + auto_settings.last_result_summary = ( + "No recent messages available for automation." + ) auto_settings.last_run_at = now - auto_settings.save(update_fields=["last_result_summary", "last_run_at", "updated_at"]) + auto_settings.save( + update_fields=["last_result_summary", "last_run_at", "updated_at"] + ) return { "ran": True, "summary": auto_settings.last_result_summary, @@ -1352,7 +2382,11 @@ def _run_auto_analysis_for_plan(user, person, conversation, plan, auto_settings, } latest_message_ts = recent_rows[-1]["ts"] - if trigger == "auto" and auto_settings.last_checked_event_ts and latest_message_ts <= auto_settings.last_checked_event_ts: + if ( + trigger == "auto" + and auto_settings.last_checked_event_ts + and latest_message_ts <= auto_settings.last_checked_event_ts + ): return { "ran": False, "summary": "Skipped: no new messages since last check.", @@ -1478,17 +2512,25 @@ def _mitigation_panel_context( ): engage_form = engage_form or {} engage_options = _engage_source_options(plan) - selected_ref = engage_form.get("source_ref") or (engage_options[0]["value"] if engage_options else "") - auto_settings = auto_settings or _get_or_create_auto_settings(plan.user, plan.conversation) + selected_ref = engage_form.get("source_ref") or ( + engage_options[0]["value"] if engage_options else "" + ) + auto_settings = auto_settings or _get_or_create_auto_settings( + plan.user, plan.conversation + ) return { "person": person, "plan": plan, + "plan_status_choices": PatternMitigationPlan.STATUS_CHOICES, + "plan_creation_mode_choices": PatternMitigationPlan.CREATION_MODE_CHOICES, "rules": plan.rules.order_by("created_at"), "games": plan.games.order_by("created_at"), "corrections": plan.corrections.order_by("created_at"), "fundamentals_text": "\n".join(plan.fundamental_items or []), "mitigation_messages": plan.messages.order_by("created_at")[:40], "latest_export": export_record, + "artifact_type_choices": PatternArtifactExport.ARTIFACT_TYPE_CHOICES, + "artifact_format_choices": PatternArtifactExport.FORMAT_CHOICES, "notice_message": notice_message, "notice_level": notice_level, "engage_preview": engage_preview, @@ -1510,9 +2552,15 @@ def _latest_plan_bundle(conversation): latest_plan = conversation.mitigation_plans.order_by("-updated_at").first() latest_plan_rules = latest_plan.rules.order_by("created_at") if latest_plan else [] latest_plan_games = latest_plan.games.order_by("created_at") if latest_plan else [] - latest_plan_corrections = latest_plan.corrections.order_by("created_at") if latest_plan else [] - latest_plan_messages = latest_plan.messages.order_by("created_at")[:40] if latest_plan else [] - latest_plan_export = latest_plan.exports.order_by("-created_at").first() if latest_plan else None + latest_plan_corrections = ( + latest_plan.corrections.order_by("created_at") if latest_plan else [] + ) + latest_plan_messages = ( + latest_plan.messages.order_by("created_at")[:40] if latest_plan else [] + ) + latest_plan_export = ( + latest_plan.exports.order_by("-created_at").first() if latest_plan else None + ) latest_auto_settings = _get_or_create_auto_settings(conversation.user, conversation) return { "latest_plan": latest_plan, @@ -1529,7 +2577,17 @@ class AIWorkspace(LoginRequiredMixin, View): template_name = "pages/ai-workspace.html" def get(self, request): - return render(request, self.template_name) + selected_person_id = "" + raw_person = str(request.GET.get("person") or "").strip() + if raw_person: + person = Person.objects.filter(id=raw_person, user=request.user).first() + if person: + selected_person_id = str(person.id) + return render( + request, + self.template_name, + {"selected_person_id": selected_person_id}, + ) class AIWorkspaceContactsWidget(LoginRequiredMixin, View): @@ -1546,9 +2604,13 @@ class AIWorkspaceContactsWidget(LoginRequiredMixin, View): { "person": person, "message_count": message_qs.count(), - "last_text": (last_message.text or "")[:120] if last_message else "", + "last_text": (last_message.text or "")[:120] + if last_message + else "", "last_ts": last_message.ts if last_message else None, - "last_ts_label": _format_unix_ms(last_message.ts) if last_message else "", + "last_ts_label": _format_unix_ms(last_message.ts) + if last_message + else "", } ) rows.sort(key=lambda row: row["last_ts"] or 0, reverse=True) @@ -1574,7 +2636,9 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View): def _message_rows(self, user, person, limit): sessions = ChatSession.objects.filter(user=user, identifier__person=person) identifiers = set( - PersonIdentifier.objects.filter(user=user, person=person).values_list("identifier", flat=True) + PersonIdentifier.objects.filter(user=user, person=person).values_list( + "identifier", flat=True + ) ) messages = ( Message.objects.filter(user=user, session__in=sessions) @@ -1608,6 +2672,7 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View): return HttpResponseBadRequest("Invalid type specified") person = get_object_or_404(Person, pk=person_id, user=request.user) + conversation = _conversation_for_person(request.user, person) try: limit = int(request.GET.get("limit", 20)) except (TypeError, ValueError): @@ -1620,6 +2685,7 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View): "window_content": "partials/ai-workspace-person-widget.html", "widget_options": 'gs-w="7" gs-h="16" gs-x="0" gs-y="0" gs-min-w="4"', "person": person, + "workspace_conversation": conversation, "limit": limit, "message_rows": self._message_rows(request.user, person, limit), "ai_operations": [ @@ -1629,10 +2695,112 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View): ("extract_patterns", "Patterns"), ], "send_state": _get_send_state(request.user, person), + "compose_page_url": _compose_page_url_for_person(request.user, person), + "manual_icon_class": "fa-solid fa-paper-plane", } return render(request, "mixins/wm/widget.html", context) +class AIWorkspaceInsightDetail(LoginRequiredMixin, View): + allowed_types = {"page", "widget"} + + def get(self, request, type, person_id, metric): + if type not in self.allowed_types: + return HttpResponseBadRequest("Invalid type specified") + spec = INSIGHT_METRICS.get(metric) + if spec is None: + return HttpResponseBadRequest("Unknown insight metric") + + person = get_object_or_404(Person, pk=person_id, user=request.user) + conversation = _conversation_for_person(request.user, person) + value = _format_metric_value(conversation, metric) + group = INSIGHT_GROUPS[spec["group"]] + points = [] + if spec["history_field"]: + points = _history_points(conversation, spec["history_field"]) + + context = { + "person": person, + "workspace_conversation": conversation, + "metric_slug": metric, + "metric": spec, + "metric_value": value, + "metric_psychology_hint": _metric_psychological_read(metric, conversation), + "metric_group": group, + "graph_points": points, + "graphs_url": reverse( + "ai_workspace_insight_graphs", + kwargs={"type": "page", "person_id": person.id}, + ), + "help_url": reverse( + "ai_workspace_insight_help", + kwargs={"type": "page", "person_id": person.id}, + ), + "workspace_url": f"{reverse('ai_workspace')}?person={person.id}", + } + return render(request, "pages/ai-workspace-insight-detail.html", context) + + +class AIWorkspaceInsightGraphs(LoginRequiredMixin, View): + allowed_types = {"page", "widget"} + + def get(self, request, type, person_id): + if type not in self.allowed_types: + return HttpResponseBadRequest("Invalid type specified") + + person = get_object_or_404(Person, pk=person_id, user=request.user) + conversation = _conversation_for_person(request.user, person) + graph_cards = _all_graph_payload(conversation) + context = { + "person": person, + "workspace_conversation": conversation, + "graph_cards": graph_cards, + "help_url": reverse( + "ai_workspace_insight_help", + kwargs={"type": "page", "person_id": person.id}, + ), + "workspace_url": f"{reverse('ai_workspace')}?person={person.id}", + } + return render(request, "pages/ai-workspace-insight-graphs.html", context) + + +class AIWorkspaceInsightHelp(LoginRequiredMixin, View): + allowed_types = {"page", "widget"} + + def get(self, request, type, person_id): + if type not in self.allowed_types: + return HttpResponseBadRequest("Invalid type specified") + + person = get_object_or_404(Person, pk=person_id, user=request.user) + conversation = _conversation_for_person(request.user, person) + metrics = [] + for slug, spec in INSIGHT_METRICS.items(): + metrics.append( + { + "slug": slug, + "title": spec["title"], + "group": spec["group"], + "group_title": INSIGHT_GROUPS[spec["group"]]["title"], + "calculation": spec["calculation"], + "psychology": spec["psychology"], + "value": _format_metric_value(conversation, slug), + } + ) + + context = { + "person": person, + "workspace_conversation": conversation, + "groups": INSIGHT_GROUPS, + "metrics": metrics, + "graphs_url": reverse( + "ai_workspace_insight_graphs", + kwargs={"type": "page", "person_id": person.id}, + ), + "workspace_url": f"{reverse('ai_workspace')}?person={person.id}", + } + return render(request, "pages/ai-workspace-insight-help.html", context) + + class AIWorkspaceRunOperation(LoginRequiredMixin, View): allowed_types = {"widget"} allowed_operations = {"artifacts", "summarise", "draft_reply", "extract_patterns"} @@ -1654,7 +2822,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View): event = MessageEvent.objects.create( user=user, conversation=conversation, - source_system="signal", + source_system=message.session.identifier.service or "signal", ts=message.ts, direction=_infer_direction(message, person_identifiers), sender_uuid=message.sender_uuid or "", @@ -1669,6 +2837,10 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View): if event.ts != message.ts: event.ts = message.ts update_fields.append("ts") + new_source_system = message.session.identifier.service or "signal" + if event.source_system != new_source_system: + event.source_system = new_source_system + update_fields.append("source_system") if event.direction != new_direction: event.direction = new_direction update_fields.append("direction") @@ -1683,7 +2855,38 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View): event_ids.append(str(event.id)) return event_ids - def _build_prompt(self, operation, person, transcript, user_notes): + def _citation_rows(self, user, citation_ids): + ids = [str(item) for item in (citation_ids or []) if item] + if not ids: + return [] + events = MessageEvent.objects.filter(user=user, id__in=ids) + by_id = {str(event.id): event for event in events} + rows = [] + for cid in ids: + event = by_id.get(cid) + if not event: + rows.append( + { + "id": cid, + "ts_label": "", + "source_system": "", + "direction": "", + "text": "", + } + ) + continue + rows.append( + { + "id": cid, + "ts_label": _format_unix_ms(event.ts), + "source_system": event.source_system, + "direction": event.direction, + "text": (event.text or "").strip(), + } + ) + return rows + + def _build_prompt(self, operation, owner_name, person, transcript, user_notes): notes = (user_notes or "").strip() if operation == "draft_reply": instruction = ( @@ -1696,14 +2899,20 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View): "Keep it actionable and concise." ) else: - instruction = ( - "Summarize this conversation window with key points, emotional state shifts, and open loops." - ) + instruction = "Summarize this conversation window with key points, emotional state shifts, and open loops." prompt = [ - {"role": "system", "content": instruction}, + { + "role": "system", + "content": ( + f"{instruction} " + "Use participant names directly. " + "Do not refer to either side as 'the user'." + ), + }, { "role": "user", "content": ( + f"Owner: {owner_name}\n" f"Person: {person.name}\n" f"Notes: {notes or 'None'}\n\n" f"Conversation:\n{transcript}" @@ -1738,7 +2947,9 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View): person, max(20, min(auto_settings.sample_message_window, 200)), ) - source_text = messages_to_string(recent_messages) if recent_messages else "" + source_text = ( + messages_to_string(recent_messages) if recent_messages else "" + ) _create_baseline_mitigation_plan( user=request.user, person=person, @@ -1769,7 +2980,9 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View): plan_bundle = _latest_plan_bundle(conversation) context = { - "operation_label": OPERATION_LABELS.get(operation, operation.replace("_", " ").title()), + "operation_label": OPERATION_LABELS.get( + operation, operation.replace("_", " ").title() + ), "operation": operation, "result_text": "", "result_sections": [], @@ -1786,10 +2999,14 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View): ai_obj = AI.objects.filter(user=request.user).first() if ai_obj is None: context = { - "operation_label": OPERATION_LABELS.get(operation, operation.replace("_", " ").title()), + "operation_label": OPERATION_LABELS.get( + operation, operation.replace("_", " ").title() + ), "operation": operation, "result_text": "No AI configured for this user yet.", - "result_sections": _parse_result_sections("No AI configured for this user yet."), + "result_sections": _parse_result_sections( + "No AI configured for this user yet." + ), "error": True, "person": person, "send_state": send_state, @@ -1809,8 +3026,22 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View): limit = max(5, min(limit, 200)) user_notes = request.GET.get("user_notes", "") - messages = AIWorkspacePersonWidget()._recent_messages(request.user, person, limit) - transcript = messages_to_string(messages) + messages = AIWorkspacePersonWidget()._recent_messages( + request.user, person, limit + ) + owner_name = ( + request.user.first_name + or request.user.get_full_name().strip() + or request.user.username + or "Me" + ) + transcript = messages_to_string( + messages, + author_rewrites={ + "USER": owner_name, + "BOT": "Assistant", + }, + ) person_identifiers = set( PersonIdentifier.objects.filter( user=request.user, @@ -1841,18 +3072,48 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View): ) try: - prompt = self._build_prompt(operation, person, transcript, user_notes) + prompt = self._build_prompt( + operation=operation, + owner_name=owner_name, + person=person, + transcript=transcript, + user_notes=user_notes, + ) result_text = async_to_sync(ai_runner.run_prompt)(prompt, ai_obj) - draft_options = _parse_draft_options(result_text) if operation == "draft_reply" else [] + draft_options = ( + _parse_draft_options(result_text) if operation == "draft_reply" else [] + ) + interaction_signals = _build_interaction_signals( + operation, + result_text, + message_event_ids, + ) + memory_proposals = _build_memory_proposals(operation, result_text) ai_result = AIResult.objects.create( user=request.user, ai_request=ai_request, working_summary=result_text if operation != "draft_reply" else "", draft_replies=draft_options, - interaction_signals=[], - memory_proposals=[], + interaction_signals=interaction_signals, + memory_proposals=memory_proposals, citations=message_event_ids, ) + first_event = None + if message_event_ids: + first_event = MessageEvent.objects.filter( + id=message_event_ids[0], + user=request.user, + ).first() + for signal in interaction_signals: + AIResultSignal.objects.create( + user=request.user, + ai_result=ai_result, + message_event=first_event, + label=signal["label"][:128], + valence=signal["valence"], + score=None, + rationale="Auto-tagged from operation output.", + ) ai_request.status = "done" ai_request.finished_at = dj_timezone.now() ai_request.save(update_fields=["status", "finished_at"]) @@ -1860,15 +3121,35 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View): conversation.save(update_fields=["last_ai_run_at"]) plan_bundle = _latest_plan_bundle(conversation) context = { - "operation_label": OPERATION_LABELS.get(operation, operation.replace("_", " ").title()), + "operation_label": OPERATION_LABELS.get( + operation, operation.replace("_", " ").title() + ), "operation": operation, "result_text": result_text, "result_sections": _parse_result_sections(result_text), "draft_replies": ai_result.draft_replies, + "interaction_signals": ai_result.interaction_signals, + "memory_proposals": ai_result.memory_proposals, + "memory_proposal_groups": _group_memory_proposals( + ai_result.memory_proposals + ), + "citations": ai_result.citations, + "citation_rows": self._citation_rows(request.user, ai_result.citations), "error": False, "person": person, "send_state": send_state, "ai_result_id": str(ai_result.id), + "ai_result_created_at": ai_result.created_at, + "ai_request_status": ai_request.status, + "ai_request_started_at": ai_request.started_at, + "ai_request_finished_at": ai_request.finished_at, + "ai_request_window_spec": ai_request.window_spec, + "ai_request_window_tags": _window_spec_tags(ai_request.window_spec), + "ai_request_message_count": len(ai_request.message_ids or []), + "ai_request_policy_snapshot": ai_request.policy_snapshot, + "ai_request_policy_tags": _policy_snapshot_tags( + ai_request.policy_snapshot + ), **plan_bundle, } except Exception as exc: @@ -1877,7 +3158,9 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View): ai_request.finished_at = dj_timezone.now() ai_request.save(update_fields=["status", "error", "finished_at"]) context = { - "operation_label": OPERATION_LABELS.get(operation, operation.replace("_", " ").title()), + "operation_label": OPERATION_LABELS.get( + operation, operation.replace("_", " ").title() + ), "operation": operation, "result_text": str(exc), "result_sections": _parse_result_sections(str(exc)), @@ -1923,12 +3206,20 @@ class AIWorkspaceSendDraft(LoginRequiredMixin, View): }, ) - identifier = _resolve_person_identifier(request.user, person) + identifier = _resolve_person_identifier( + request.user, + person, + preferred_service=_preferred_service_for_person(request.user, person), + ) if identifier is None: return render( request, "partials/ai-workspace-send-status.html", - {"ok": False, "message": "No recipient identifier found.", "level": "danger"}, + { + "ok": False, + "message": "No recipient identifier found.", + "level": "danger", + }, ) try: @@ -1939,12 +3230,22 @@ class AIWorkspaceSendDraft(LoginRequiredMixin, View): "partials/ai-workspace-send-status.html", {"ok": False, "message": f"Send failed: {exc}", "level": "danger"}, ) + if ts is False or ts is None: + return render( + request, + "partials/ai-workspace-send-status.html", + {"ok": False, "message": "Send failed.", "level": "danger"}, + ) session, _ = ChatSession.objects.get_or_create( user=request.user, identifier=identifier, ) - sent_ts = int(ts) if ts else int(dj_timezone.now().timestamp() * 1000) + sent_ts = ( + int(ts) + if (ts is not None and not isinstance(ts, bool)) + else int(dj_timezone.now().timestamp() * 1000) + ) Message.objects.create( user=request.user, session=session, @@ -1952,6 +3253,8 @@ class AIWorkspaceSendDraft(LoginRequiredMixin, View): sender_uuid="", text=text, ts=sent_ts, + delivered_ts=sent_ts, + read_source_service=identifier.service, ) success_message = "Draft sent." if force_send and not send_state["can_send"]: @@ -1987,15 +3290,27 @@ class AIWorkspaceQueueDraft(LoginRequiredMixin, View): return render( request, "partials/ai-workspace-send-status.html", - {"ok": False, "message": "Select a draft before queueing.", "level": "warning"}, + { + "ok": False, + "message": "Select a draft before queueing.", + "level": "warning", + }, ) - identifier = _resolve_person_identifier(request.user, person) + identifier = _resolve_person_identifier( + request.user, + person, + preferred_service=_preferred_service_for_person(request.user, person), + ) if identifier is None: return render( request, "partials/ai-workspace-send-status.html", - {"ok": False, "message": "No recipient identifier found.", "level": "danger"}, + { + "ok": False, + "message": "No recipient identifier found.", + "level": "danger", + }, ) manipulation = _get_queue_manipulation(request.user, person) @@ -2057,10 +3372,14 @@ class AIWorkspaceCreateMitigation(LoginRequiredMixin, View): source_result = None if ai_result_id: - source_result = AIResult.objects.filter( - id=ai_result_id, - user=request.user, - ).select_related("ai_request", "ai_request__conversation").first() + source_result = ( + AIResult.objects.filter( + id=ai_result_id, + user=request.user, + ) + .select_related("ai_request", "ai_request__conversation") + .first() + ) conversation = ( source_result.ai_request.conversation @@ -2150,7 +3469,9 @@ class AIWorkspaceMitigationChat(LoginRequiredMixin, View): user=request.user, ) text = (request.POST.get("message") or "").strip() - active_tab = _sanitize_active_tab(request.POST.get("active_tab"), default="ask_ai") + active_tab = _sanitize_active_tab( + request.POST.get("active_tab"), default="ask_ai" + ) if not text: return render( request, @@ -2174,9 +3495,21 @@ class AIWorkspaceMitigationChat(LoginRequiredMixin, View): ai_obj = AI.objects.filter(user=request.user).first() assistant_text = "" if ai_obj: - rules_text = "\n".join([f"- {r.title}: {r.content}" for r in plan.rules.order_by("created_at")]) - games_text = "\n".join([f"- {g.title}: {g.instructions}" for g in plan.games.order_by("created_at")]) - corrections_text = "\n".join([f"- {c.title}: {c.clarification}" for c in plan.corrections.order_by("created_at")]) + rules_text = "\n".join( + [f"- {r.title}: {r.content}" for r in plan.rules.order_by("created_at")] + ) + games_text = "\n".join( + [ + f"- {g.title}: {g.instructions}" + for g in plan.games.order_by("created_at") + ] + ) + corrections_text = "\n".join( + [ + f"- {c.title}: {c.clarification}" + for c in plan.corrections.order_by("created_at") + ] + ) recent_msgs = plan.messages.order_by("-created_at")[:10] recent_msgs = list(reversed(list(recent_msgs))) transcript = "\n".join([f"{m.role.upper()}: {m.text}" for m in recent_msgs]) @@ -2205,7 +3538,9 @@ class AIWorkspaceMitigationChat(LoginRequiredMixin, View): except Exception as exc: assistant_text = f"Failed to run AI refinement: {exc}" else: - assistant_text = "No AI configured. Add an AI config to use mitigation chat." + assistant_text = ( + "No AI configured. Add an AI config to use mitigation chat." + ) PatternMitigationMessage.objects.create( user=request.user, @@ -2244,7 +3579,9 @@ class AIWorkspaceExportArtifact(LoginRequiredMixin, View): artifact_type = "rulebook" export_format = (request.POST.get("export_format") or "markdown").strip() - active_tab = _sanitize_active_tab(request.POST.get("active_tab"), default="ask_ai") + active_tab = _sanitize_active_tab( + request.POST.get("active_tab"), default="ask_ai" + ) if export_format not in {"markdown", "json", "text"}: export_format = "markdown" @@ -2295,7 +3632,9 @@ class AIWorkspaceCreateArtifact(LoginRequiredMixin, View): if kind_key == "correction": candidate_signature = _correction_signature(f"New {label}", "") if candidate_signature in _existing_correction_signatures(plan): - tab = _sanitize_active_tab(request.POST.get("active_tab"), default="corrections") + tab = _sanitize_active_tab( + request.POST.get("active_tab"), default="corrections" + ) return render( request, "partials/ai-workspace-mitigation-panel.html", @@ -2366,9 +3705,13 @@ class AIWorkspaceUpdateArtifact(LoginRequiredMixin, View): ) if kind_key == "correction": - title = _normalize_correction_title(title, fallback=artifact.title or "Correction") + title = _normalize_correction_title( + title, fallback=artifact.title or "Correction" + ) candidate_signature = _correction_signature(title, body) - if candidate_signature in _existing_correction_signatures(plan, exclude_id=artifact.id): + if candidate_signature in _existing_correction_signatures( + plan, exclude_id=artifact.id + ): return render( request, "partials/ai-workspace-mitigation-panel.html", @@ -2522,7 +3865,9 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View): "framing": framing, "context_note": context_note, } - active_tab = _sanitize_active_tab(request.POST.get("active_tab"), default="engage") + active_tab = _sanitize_active_tab( + request.POST.get("active_tab"), default="engage" + ) if ":" not in source_ref: return render( @@ -2615,7 +3960,11 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View): ), ) - identifier = _resolve_person_identifier(request.user, person) + identifier = _resolve_person_identifier( + request.user, + person, + preferred_service=plan.conversation.platform_type, + ) if identifier is None: return render( request, @@ -2647,12 +3996,30 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View): active_tab=active_tab, ), ) + if ts is False or ts is None: + return render( + request, + "partials/ai-workspace-mitigation-panel.html", + _mitigation_panel_context( + person=person, + plan=plan, + notice_message="Send failed.", + notice_level="danger", + engage_preview=engage_preview, + engage_form=engage_form, + active_tab=active_tab, + ), + ) session, _ = ChatSession.objects.get_or_create( user=request.user, identifier=identifier, ) - sent_ts = int(ts) if ts else int(dj_timezone.now().timestamp() * 1000) + sent_ts = ( + int(ts) + if (ts is not None and not isinstance(ts, bool)) + else int(dj_timezone.now().timestamp() * 1000) + ) Message.objects.create( user=request.user, session=session, @@ -2660,6 +4027,8 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View): sender_uuid="", text=outbound_text, ts=sent_ts, + delivered_ts=sent_ts, + read_source_service=identifier.service, ) notice = "Shared via engage." if force_send and not send_state["can_send"]: @@ -2690,7 +4059,11 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View): return response if action == "queue": - identifier = _resolve_person_identifier(request.user, person) + identifier = _resolve_person_identifier( + request.user, + person, + preferred_service=plan.conversation.platform_type, + ) if identifier is None: return render( request, @@ -2789,11 +4162,11 @@ class AIWorkspaceAutoSettings(LoginRequiredMixin, View): request.POST.get("auto_notify_enabled") ) auto_settings.ntfy_topic_override = ( - (request.POST.get("ntfy_topic_override") or "").strip() or None - ) + request.POST.get("ntfy_topic_override") or "" + ).strip() or None auto_settings.ntfy_url_override = ( - (request.POST.get("ntfy_url_override") or "").strip() or None - ) + request.POST.get("ntfy_url_override") or "" + ).strip() or None try: auto_settings.sample_message_window = max( 10, min(int(request.POST.get("sample_message_window") or 40), 200) @@ -2848,7 +4221,9 @@ class AIWorkspaceUpdateFundamentals(LoginRequiredMixin, View): person = get_object_or_404(Person, pk=person_id, user=request.user) plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user) fundamentals_text = request.POST.get("fundamentals_text") or "" - active_tab = _sanitize_active_tab(request.POST.get("active_tab"), default="fundamentals") + active_tab = _sanitize_active_tab( + request.POST.get("active_tab"), default="fundamentals" + ) plan.fundamental_items = _parse_fundamentals(fundamentals_text) plan.save(update_fields=["fundamental_items", "updated_at"]) return render( @@ -2862,3 +4237,59 @@ class AIWorkspaceUpdateFundamentals(LoginRequiredMixin, View): active_tab=active_tab, ), ) + + +class AIWorkspaceUpdatePlanMeta(LoginRequiredMixin, View): + allowed_types = {"widget"} + + def post(self, request, type, person_id, plan_id): + if type not in self.allowed_types: + return HttpResponseBadRequest("Invalid type specified") + + person = get_object_or_404(Person, pk=person_id, user=request.user) + plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user) + active_tab = _sanitize_active_tab( + request.POST.get("active_tab"), default="plan_board" + ) + + status_value = (request.POST.get("status") or "").strip() + creation_mode_value = (request.POST.get("creation_mode") or "").strip() + title_value = (request.POST.get("title") or "").strip() + objective_value = (request.POST.get("objective") or "").strip() + + valid_statuses = {key for key, _label in PatternMitigationPlan.STATUS_CHOICES} + valid_modes = { + key for key, _label in PatternMitigationPlan.CREATION_MODE_CHOICES + } + update_fields = ["updated_at"] + + if status_value in valid_statuses: + if plan.status != status_value: + plan.status = status_value + update_fields.append("status") + if creation_mode_value in valid_modes: + if plan.creation_mode != creation_mode_value: + plan.creation_mode = creation_mode_value + update_fields.append("creation_mode") + + if plan.title != title_value: + plan.title = title_value[:255] + update_fields.append("title") + if plan.objective != objective_value: + plan.objective = objective_value + update_fields.append("objective") + + if len(update_fields) > 1: + plan.save(update_fields=update_fields) + + return render( + request, + "partials/ai-workspace-mitigation-panel.html", + _mitigation_panel_context( + person=person, + plan=plan, + notice_message="Plan metadata saved.", + notice_level="success", + active_tab=active_tab, + ), + ) diff --git a/docker-compose.yml b/docker-compose.yml index 0379774..3f8d3ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,8 @@ services: REGISTRATION_OPEN: "${REGISTRATION_OPEN}" OPERATION: "${OPERATION}" SIGNAL_NUMBER: "${SIGNAL_NUMBER}" + WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}" + INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}" XMPP_ADDRESS: "${XMPP_ADDRESS}" XMPP_JID: "${XMPP_JID}" XMPP_PORT: "${XMPP_PORT}" @@ -103,6 +105,8 @@ services: REGISTRATION_OPEN: "${REGISTRATION_OPEN}" OPERATION: "${OPERATION}" SIGNAL_NUMBER: "${SIGNAL_NUMBER}" + WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}" + INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}" XMPP_ADDRESS: "${XMPP_ADDRESS}" XMPP_JID: "${XMPP_JID}" XMPP_PORT: "${XMPP_PORT}" @@ -152,6 +156,8 @@ services: REGISTRATION_OPEN: "${REGISTRATION_OPEN}" OPERATION: "${OPERATION}" SIGNAL_NUMBER: "${SIGNAL_NUMBER}" + WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}" + INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}" XMPP_ADDRESS: "${XMPP_ADDRESS}" XMPP_JID: "${XMPP_JID}" XMPP_PORT: "${XMPP_PORT}" @@ -200,6 +206,8 @@ services: REGISTRATION_OPEN: "${REGISTRATION_OPEN}" OPERATION: "${OPERATION}" SIGNAL_NUMBER: "${SIGNAL_NUMBER}" + WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}" + INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}" XMPP_ADDRESS: "${XMPP_ADDRESS}" XMPP_JID: "${XMPP_JID}" XMPP_PORT: "${XMPP_PORT}" @@ -241,6 +249,8 @@ services: REGISTRATION_OPEN: "${REGISTRATION_OPEN}" OPERATION: "${OPERATION}" SIGNAL_NUMBER: "${SIGNAL_NUMBER}" + WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}" + INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}" XMPP_ADDRESS: "${XMPP_ADDRESS}" XMPP_JID: "${XMPP_JID}" XMPP_PORT: "${XMPP_PORT}" diff --git a/oom b/oom new file mode 100644 index 0000000..e69de29