Continue AI features and improve protocol support

This commit is contained in:
2026-02-15 16:57:32 +00:00
parent 2d3b8fdac6
commit 85e97e895d
62 changed files with 5472 additions and 441 deletions

View File

@@ -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")

View File

@@ -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/<str:type>/",
signal.SignalAccounts.as_view(),
name="signal_accounts",
),
path(
"services/whatsapp/<str:type>/",
whatsapp.WhatsAppAccounts.as_view(),
name="whatsapp_accounts",
),
path(
"services/instagram/<str:type>/",
instagram.InstagramAccounts.as_view(),
name="instagram_accounts",
),
path(
"services/signal/<str:type>/contacts/<str:pk>/",
signal.SignalContactsList.as_view(),
@@ -81,6 +103,41 @@ urlpatterns = [
signal.SignalAccountAdd.as_view(),
name="signal_account_add",
),
path(
"services/whatsapp/<str:type>/add/",
whatsapp.WhatsAppAccountAdd.as_view(),
name="whatsapp_account_add",
),
path(
"services/instagram/<str:type>/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/<str:type>/person/<uuid:person_id>/insights/graphs/",
workspace.AIWorkspaceInsightGraphs.as_view(),
name="ai_workspace_insight_graphs",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/insights/help/",
workspace.AIWorkspaceInsightHelp.as_view(),
name="ai_workspace_insight_help",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/insights/<str:metric>/",
workspace.AIWorkspaceInsightDetail.as_view(),
name="ai_workspace_insight_detail",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/run/<str:operation>/",
workspace.AIWorkspaceRunOperation.as_view(),
@@ -118,50 +190,65 @@ urlpatterns = [
name="ai_workspace_mitigation_create",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/chat/",
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
"<uuid:plan_id>/chat/",
workspace.AIWorkspaceMitigationChat.as_view(),
name="ai_workspace_mitigation_chat",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/export/",
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
"<uuid:plan_id>/export/",
workspace.AIWorkspaceExportArtifact.as_view(),
name="ai_workspace_mitigation_export",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/artifact/create/<str:kind>/",
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
"<uuid:plan_id>/artifact/create/<str:kind>/",
workspace.AIWorkspaceCreateArtifact.as_view(),
name="ai_workspace_mitigation_artifact_create",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/artifact/<str:kind>/<uuid:artifact_id>/save/",
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
"<uuid:plan_id>/artifact/<str:kind>/<uuid:artifact_id>/save/",
workspace.AIWorkspaceUpdateArtifact.as_view(),
name="ai_workspace_mitigation_artifact_save",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/artifact/<str:kind>/<uuid:artifact_id>/delete/",
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
"<uuid:plan_id>/artifact/<str:kind>/<uuid:artifact_id>/delete/",
workspace.AIWorkspaceDeleteArtifact.as_view(),
name="ai_workspace_mitigation_artifact_delete",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/artifact/<str:kind>/delete-all/",
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
"<uuid:plan_id>/artifact/<str:kind>/delete-all/",
workspace.AIWorkspaceDeleteArtifactList.as_view(),
name="ai_workspace_mitigation_artifact_delete_all",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/engage/share/",
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
"<uuid:plan_id>/engage/share/",
workspace.AIWorkspaceEngageShare.as_view(),
name="ai_workspace_mitigation_engage_share",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/auto/",
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
"<uuid:plan_id>/auto/",
workspace.AIWorkspaceAutoSettings.as_view(),
name="ai_workspace_mitigation_auto",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/fundamentals/save/",
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
"<uuid:plan_id>/fundamentals/save/",
workspace.AIWorkspaceUpdateFundamentals.as_view(),
name="ai_workspace_mitigation_fundamentals_save",
),
path(
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
"<uuid:plan_id>/meta/save/",
workspace.AIWorkspaceUpdatePlanMeta.as_view(),
name="ai_workspace_mitigation_meta_save",
),
path(
"ai/<str:type>/",
ais.AIList.as_view(),

View File

@@ -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

View File

@@ -1,7 +1,6 @@
import os
# import stripe
from django.conf import settings
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
# from redis import StrictRedis

View File

@@ -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)

185
core/clients/gateway.py Normal file
View File

@@ -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,
)

View File

@@ -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)

View File

@@ -0,0 +1,8 @@
"""
Backward-compatible compatibility layer.
Prefer importing from `core.clients.transport`.
"""
from core.clients.transport import * # noqa: F401,F403

View File

@@ -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:
if settings.DEBUG:
SIGNAL_HOST = "127.0.0.1"
else:
SIGNAL_HOST = "signal"
SIGNAL_PORT = 8080
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

View File

@@ -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

417
core/clients/transport.py Normal file
View File

@@ -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")

627
core/clients/whatsapp.py Normal file
View File

@@ -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

View File

@@ -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 <person>|<title> | "
".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

View File

@@ -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:

View File

@@ -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.",
}

View File

@@ -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
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,

View File

@@ -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

View File

@@ -1,6 +1,5 @@
import asyncio
from django.conf import settings
from django.core.management.base import BaseCommand
from core.modules.router import UnifiedRouter

View File

@@ -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(

View File

@@ -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,

View File

@@ -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

View File

@@ -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"),
}

View File

@@ -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:

View File

@@ -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")

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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),
),
]

View File

@@ -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')],
},
),
]

View File

@@ -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(
return await transport.send_message_raw(
self.service,
self.identifier,
text,
attachments,
text=text,
attachments=attachments or [],
)
print("SENT")
return ts
else:
raise NotImplementedError(f"Service not implemented: {self.service}")
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(

View File

@@ -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 {}),
)

View File

@@ -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}")

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -3,7 +3,7 @@
{% 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"

View File

@@ -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>

View File

@@ -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>
<input type="hidden" name="enabled" value="1">
</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>
<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 %}

View File

@@ -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;">

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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

View File

@@ -1,2 +1 @@
<img src="data:image/png;base64, {{ object }}" alt="Signal QR code" />
<img src="data:image/png;base64, {{ object }}" alt="Service QR code" />

View File

@@ -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,6 +36,7 @@
</span>
</span>
</button>
{% if show_contact_actions %}
{% if type == 'page' %}
<a href="{% url 'signal_contacts' type=type pk=item %}"><button
class="button">
@@ -78,6 +84,7 @@
</span>
</button>
{% endif %}
{% endif %}
</div>
</td>
</tr>
@@ -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,11 +113,9 @@
<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>

View File

@@ -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">
{% 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="fa-solid fa-eye"></i>
</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>
<i class="{{ item.manual_icon_class }}"></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">
<button class="button" disabled title="No identifier available for manual send">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-eye"></i>
<i class="{{ item.manual_icon_class }}"></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">
{% 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 %}
{% 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-brain-circuit"></i>
</span>
</span>
</button></a>
{% endif %}
</div>
</td>

View File

@@ -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

369
core/views/compose.py Normal file
View File

@@ -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

29
core/views/instagram.py Normal file
View File

@@ -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"

View File

@@ -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()

View File

@@ -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

29
core/views/whatsapp.py Normal file
View File

@@ -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"

File diff suppressed because it is too large Load Diff

View File

@@ -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}"

0
oom Normal file
View File