Work on fixing bugs and reformat

This commit is contained in:
2026-02-16 16:01:17 +00:00
parent 8ca1695fab
commit 3f82c27ab9
32 changed files with 1100 additions and 442 deletions

View File

@@ -7,8 +7,8 @@ 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
from core.modules.mixed_protocol import normalize_gateway_event
class GatewayClient(ClientBase):
@@ -48,7 +48,9 @@ class GatewayClient(ClientBase):
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.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):

View File

@@ -5,4 +5,3 @@ Prefer importing from `core.clients.transport`.
"""
from core.clients.transport import * # noqa: F401,F403

View File

@@ -456,7 +456,9 @@ class HandleMessage(Command):
if session_key in session_cache:
chat_session = session_cache[session_key]
else:
chat_session = await history.get_chat_session(identifier.user, identifier)
chat_session = await history.get_chat_session(
identifier.user, identifier
)
session_cache[session_key] = chat_session
sender_key = source_uuid or source_number or identifier_candidates[0]
message_key = (chat_session.id, ts, sender_key)

View File

@@ -3,8 +3,8 @@ import base64
import io
import secrets
import time
from urllib.parse import quote_plus
from typing import Any
from urllib.parse import quote_plus
import aiohttp
import orjson
@@ -40,6 +40,10 @@ def _runtime_command_result_key(service: str, command_id: str) -> str:
return f"gia:service:command-result:{_service_key(service)}:{command_id}"
def _runtime_command_cancel_key(service: str, command_id: str) -> str:
return f"gia:service:command-cancel:{_service_key(service)}:{command_id}"
def _gateway_base(service: str) -> str:
key = f"{service.upper()}_HTTP_URL"
default = f"http://{service}:8080"
@@ -88,7 +92,9 @@ def update_runtime_state(service: str, **updates):
return state
def enqueue_runtime_command(service: str, action: str, payload: dict | None = None) -> str:
def enqueue_runtime_command(
service: str, action: str, payload: dict | None = None
) -> str:
service_key = _service_key(service)
command_id = secrets.token_hex(12)
command = {
@@ -118,7 +124,9 @@ def pop_runtime_command(service: str) -> dict[str, Any] | None:
return command
def set_runtime_command_result(service: str, command_id: str, result: dict | None = None):
def set_runtime_command_result(
service: str, command_id: str, result: dict | None = None
):
service_key = _service_key(service)
result_key = _runtime_command_result_key(service_key, command_id)
payload = dict(result or {})
@@ -126,7 +134,36 @@ def set_runtime_command_result(service: str, command_id: str, result: dict | Non
cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL)
async def wait_runtime_command_result(service: str, command_id: str, timeout: float = 20.0):
def cancel_runtime_command(service: str, command_id: str):
"""Mark a runtime command as cancelled and set a result so waiters are released."""
service_key = _service_key(service)
result_key = _runtime_command_result_key(service_key, command_id)
cancel_key = _runtime_command_cancel_key(service_key, command_id)
payload = {"ok": False, "error": "cancelled", "completed_at": int(time.time())}
cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL)
cache.set(cancel_key, True, timeout=60)
return True
def cancel_runtime_commands_for_recipient(service: str, recipient: str) -> list[str]:
"""Cancel any queued runtime commands for the given recipient and return their ids."""
service_key = _service_key(service)
key = _runtime_commands_key(service_key)
queued = list(cache.get(key) or [])
cancelled = []
for cmd in list(queued):
payload = dict(cmd.get("payload") or {})
if str(payload.get("recipient") or "").strip() == str(recipient or "").strip():
cmd_id = str(cmd.get("id") or "").strip()
if cmd_id:
cancel_runtime_command(service_key, cmd_id)
cancelled.append(cmd_id)
return cancelled
async def wait_runtime_command_result(
service: str, command_id: str, timeout: float = 20.0
):
service_key = _service_key(service)
result_key = _runtime_command_result_key(service_key, command_id)
deadline = time.monotonic() + max(0.1, float(timeout or 0.0))
@@ -149,7 +186,9 @@ def list_accounts(service: str):
if service_key == "signal":
import requests
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip("/")
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:
@@ -199,7 +238,9 @@ def unlink_account(service: str, account: str) -> bool:
if service_key == "signal":
import requests
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip("/")
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
"/"
)
target = quote_plus(account_value)
for path in (f"/v1/accounts/{target}", f"/v1/account/{target}"):
try:
@@ -242,7 +283,9 @@ def unlink_account(service: str, account: str) -> bool:
connected=bool(accounts),
pair_status=("connected" if accounts else ""),
pair_qr="",
warning=("" if accounts else "Account unlinked. Add account to link again."),
warning=(
"" if accounts else "Account unlinked. Add account to link again."
),
last_event="account_unlinked",
last_error="",
)
@@ -619,7 +662,9 @@ def get_link_qr(service: str, device_name: str):
if service_key == "signal":
import requests
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip("/")
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
"/"
)
response = requests.get(
f"{base}/v1/qrcodelink",
params={"device_name": device},

View File

@@ -1,4 +1,5 @@
import asyncio
import logging
import os
import re
import sqlite3
@@ -8,6 +9,7 @@ from urllib.parse import quote_plus
import aiohttp
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core.cache import cache
from core.clients import ClientBase, transport
from core.messaging import history, media_bridge
@@ -45,12 +47,11 @@ class WhatsAppClient(ClientBase):
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()
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()
safe_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", self.client_name) or "gia_whatsapp"
# Use a persistent default path (under project mount) instead of /tmp so
# link state and contact cache survive container restarts.
@@ -70,14 +71,24 @@ class WhatsAppClient(ClientBase):
self._publish_state(
connected=False,
warning=(
"WhatsApp runtime is disabled by settings."
if not self.enabled
else ""
"WhatsApp runtime is disabled by settings." if not self.enabled else ""
),
accounts=prior_accounts,
last_event="init",
session_db=self.session_db,
)
# Reduce third-party library logging noise (neonize/grpc/protobuf).
try:
# Common noisy libraries used by Neonize/WhatsApp stacks
logging.getLogger("neonize").setLevel(logging.WARNING)
logging.getLogger("grpc").setLevel(logging.WARNING)
logging.getLogger("google").setLevel(logging.WARNING)
logging.getLogger("protobuf").setLevel(logging.WARNING)
logging.getLogger("whatsmeow").setLevel(logging.WARNING)
logging.getLogger("whatsmeow.Client").setLevel(logging.WARNING)
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
except Exception:
pass
def _publish_state(self, **updates):
state = transport.update_runtime_state(self.service, **updates)
@@ -96,8 +107,9 @@ class WhatsAppClient(ClientBase):
async def _run(self):
try:
import neonize.aioze.client as wa_client_mod
from neonize.aioze.client import NewAClient
from neonize.aioze import events as wa_events
from neonize.aioze.client import NewAClient
try:
from neonize.utils.enum import ChatPresence, ChatPresenceMedia
except Exception:
@@ -432,7 +444,9 @@ class WhatsAppClient(ClientBase):
try:
return cls(self.session_db)
except Exception as exc:
self.log.warning("whatsapp client init failed (%s): %s", self.session_db, exc)
self.log.warning(
"whatsapp client init failed (%s): %s", self.session_db, exc
)
self._publish_state(
last_event="client_init_exception",
last_error=str(exc),
@@ -611,7 +625,9 @@ class WhatsAppClient(ClientBase):
if offline_sync_completed_ev is not None:
async def on_offline_sync_completed(client, event: offline_sync_completed_ev):
async def on_offline_sync_completed(
client, event: offline_sync_completed_ev
):
self._publish_state(last_event="offline_sync_completed")
await self._sync_contacts_from_client()
@@ -633,8 +649,7 @@ class WhatsAppClient(ClientBase):
self._publish_state(
last_event="pair_waiting_no_qr",
warning=(
"Waiting for WhatsApp QR from Neonize. "
"No QR callback received yet."
"Waiting for WhatsApp QR from Neonize. " "No QR callback received yet."
),
)
@@ -663,10 +678,12 @@ class WhatsAppClient(ClientBase):
text = payload.get("text")
attachments = payload.get("attachments") or []
try:
# Include command_id so send_message_raw can observe cancel requests
result = await self.send_message_raw(
recipient=recipient,
text=text,
attachments=attachments,
command_id=command_id,
)
if result is not False and result is not None:
transport.set_runtime_command_result(
@@ -778,10 +795,14 @@ class WhatsAppClient(ClientBase):
state = transport.get_runtime_state(self.service)
return {
"started_at": started_at,
"finished_at": int(state.get("history_sync_finished_at") or int(time.time())),
"finished_at": int(
state.get("history_sync_finished_at") or int(time.time())
),
"duration_ms": int(state.get("history_sync_duration_ms") or 0),
"contacts_sync_count": int(state.get("contacts_sync_count") or 0),
"history_imported_messages": int(state.get("history_imported_messages") or 0),
"history_imported_messages": int(
state.get("history_imported_messages") or 0
),
"sqlite_imported_messages": int(state.get("history_sqlite_imported") or 0),
"sqlite_scanned_messages": int(state.get("history_sqlite_scanned") or 0),
"sqlite_table": str(state.get("history_sqlite_table") or ""),
@@ -838,7 +859,9 @@ class WhatsAppClient(ClientBase):
ID=str(anchor.get("msg_id") or ""),
Timestamp=int(anchor.get("ts") or int(time.time() * 1000)),
)
request_msg = build_history_sync_request(info, max(10, min(int(count or 120), 500)))
request_msg = build_history_sync_request(
info, max(10, min(int(count or 120), 500))
)
await self._maybe_await(self._client.send_message(chat_jid, request_msg))
self._publish_state(
last_event="history_on_demand_requested",
@@ -966,11 +989,17 @@ class WhatsAppClient(ClientBase):
selected = name
break
if not selected:
return {"rows": [], "table": "", "error": "messages_table_not_found"}
return {
"rows": [],
"table": "",
"error": "messages_table_not_found",
}
columns = [
str(row[1] or "")
for row in cur.execute(f'PRAGMA table_info("{selected}")').fetchall()
for row in cur.execute(
f'PRAGMA table_info("{selected}")'
).fetchall()
]
if not columns:
return {"rows": [], "table": selected, "error": "no_columns"}
@@ -1039,7 +1068,11 @@ class WhatsAppClient(ClientBase):
"table": selected,
"error": "required_message_columns_missing",
}
select_cols = [col for col in [text_col, ts_col, from_me_col, sender_col, chat_col] if col]
select_cols = [
col
for col in [text_col, ts_col, from_me_col, sender_col, chat_col]
if col
]
quoted = ", ".join(f'"{col}"' for col in select_cols)
order_expr = f'"{ts_col}" DESC' if ts_col else "ROWID DESC"
sql = f'SELECT {quoted} FROM "{selected}" ORDER BY {order_expr} LIMIT 12000'
@@ -1057,8 +1090,12 @@ class WhatsAppClient(ClientBase):
text = str(row_map.get(text_col) or "").strip()
if not text:
continue
raw_sender = str(row_map.get(sender_col) or "").strip() if sender_col else ""
raw_chat = str(row_map.get(chat_col) or "").strip() if chat_col else ""
raw_sender = (
str(row_map.get(sender_col) or "").strip() if sender_col else ""
)
raw_chat = (
str(row_map.get(chat_col) or "").strip() if chat_col else ""
)
raw_from_me = row_map.get(from_me_col) if from_me_col else None
parsed.append(
{
@@ -1089,7 +1126,9 @@ class WhatsAppClient(ClientBase):
target_candidates = self._normalize_identifier_candidates(identifier)
target_local = str(identifier or "").strip().split("@", 1)[0]
if target_local:
target_candidates.update(self._normalize_identifier_candidates(target_local))
target_candidates.update(
self._normalize_identifier_candidates(target_local)
)
identifiers = await sync_to_async(list)(
PersonIdentifier.objects.filter(service="whatsapp")
@@ -1348,7 +1387,9 @@ class WhatsAppClient(ClientBase):
for row in cur.execute(
'SELECT DISTINCT "our_jid" FROM "whatsmeow_contacts"'
).fetchall():
base = str((row or [None])[0] or "").strip().split("@", 1)[0]
base = (
str((row or [None])[0] or "").strip().split("@", 1)[0]
)
base = base.split(":", 1)[0]
if base:
own_ids.add(base.lower())
@@ -1364,9 +1405,18 @@ class WhatsAppClient(ClientBase):
).fetchall()
except Exception:
rows = []
for their_jid, first_name, full_name, push_name, business_name in rows:
for (
their_jid,
first_name,
full_name,
push_name,
business_name,
) in rows:
jid_value = str(their_jid or "").strip()
if "@s.whatsapp.net" not in jid_value and "@lid" not in jid_value:
if (
"@s.whatsapp.net" not in jid_value
and "@lid" not in jid_value
):
continue
identifier = jid_value.split("@", 1)[0].strip().split(":", 1)[0]
if "@lid" in jid_value:
@@ -1539,7 +1589,9 @@ class WhatsAppClient(ClientBase):
me = await self._maybe_await(self._client.get_me())
if me:
self._connected = True
self._publish_state(connected=True, warning="", pair_status="connected")
self._publish_state(
connected=True, warning="", pair_status="connected"
)
return True
except Exception:
pass
@@ -1561,22 +1613,17 @@ class WhatsAppClient(ClientBase):
return
pushname_rows = (
self._pluck(data, "pushnames")
or self._pluck(data, "Pushnames")
or []
self._pluck(data, "pushnames") or self._pluck(data, "Pushnames") or []
)
pushname_map = {}
for row in pushname_rows:
raw_id = (
self._jid_to_identifier(self._pluck(row, "ID"))
or self._jid_to_identifier(self._pluck(row, "id"))
)
raw_id = self._jid_to_identifier(
self._pluck(row, "ID")
) or self._jid_to_identifier(self._pluck(row, "id"))
if not raw_id:
continue
pushname = str(
self._pluck(row, "pushname")
or self._pluck(row, "Pushname")
or ""
self._pluck(row, "pushname") or self._pluck(row, "Pushname") or ""
).strip()
if not pushname:
continue
@@ -1693,12 +1740,20 @@ class WhatsAppClient(ClientBase):
self._pluck(msg_obj, "videoMessage", "caption"),
self._pluck(msg_obj, "documentMessage", "caption"),
self._pluck(msg_obj, "ephemeralMessage", "message", "conversation"),
self._pluck(msg_obj, "ephemeralMessage", "message", "extendedTextMessage", "text"),
self._pluck(
msg_obj, "ephemeralMessage", "message", "extendedTextMessage", "text"
),
self._pluck(msg_obj, "viewOnceMessage", "message", "conversation"),
self._pluck(msg_obj, "viewOnceMessage", "message", "extendedTextMessage", "text"),
self._pluck(
msg_obj, "viewOnceMessage", "message", "extendedTextMessage", "text"
),
self._pluck(msg_obj, "viewOnceMessageV2", "message", "conversation"),
self._pluck(msg_obj, "viewOnceMessageV2", "message", "extendedTextMessage", "text"),
self._pluck(msg_obj, "viewOnceMessageV2Extension", "message", "conversation"),
self._pluck(
msg_obj, "viewOnceMessageV2", "message", "extendedTextMessage", "text"
),
self._pluck(
msg_obj, "viewOnceMessageV2Extension", "message", "conversation"
),
self._pluck(
msg_obj,
"viewOnceMessageV2Extension",
@@ -1753,7 +1808,9 @@ class WhatsAppClient(ClientBase):
)
return str(sender or fallback_chat_jid or "").strip()
async def _import_history_messages_for_conversation(self, row, chat_jid: str) -> int:
async def _import_history_messages_for_conversation(
self, row, chat_jid: str
) -> int:
imported = 0
msg_rows = self._history_message_rows(row)
if not msg_rows:
@@ -1761,7 +1818,9 @@ class WhatsAppClient(ClientBase):
chat_identifier = str(chat_jid or "").split("@", 1)[0].strip()
if not chat_identifier:
return imported
candidate_values = self._normalize_identifier_candidates(chat_jid, chat_identifier)
candidate_values = self._normalize_identifier_candidates(
chat_jid, chat_identifier
)
if not candidate_values:
return imported
identifiers = await sync_to_async(list)(
@@ -1968,8 +2027,7 @@ class WhatsAppClient(ClientBase):
or self._pluck(event, "info", "messageSource")
)
is_from_me = bool(
self._pluck(source, "IsFromMe")
or self._pluck(source, "isFromMe")
self._pluck(source, "IsFromMe") or self._pluck(source, "isFromMe")
)
sender = self._jid_to_identifier(
@@ -1979,8 +2037,7 @@ class WhatsAppClient(ClientBase):
or self._pluck(source, "senderAlt")
)
chat = self._jid_to_identifier(
self._pluck(source, "Chat")
or self._pluck(source, "chat")
self._pluck(source, "Chat") or self._pluck(source, "chat")
)
raw_ts = (
self._pluck(event, "Info", "Timestamp")
@@ -2092,14 +2149,15 @@ class WhatsAppClient(ClientBase):
or self._pluck(event, "info", "messageSource")
)
sender = self._jid_to_identifier(
self._pluck(source, "Sender")
or self._pluck(source, "sender")
self._pluck(source, "Sender") or self._pluck(source, "sender")
)
chat = self._jid_to_identifier(
self._pluck(source, "Chat") or self._pluck(source, "chat")
)
timestamps = []
raw_ids = self._pluck(event, "MessageIDs") or self._pluck(event, "message_ids") or []
raw_ids = (
self._pluck(event, "MessageIDs") or self._pluck(event, "message_ids") or []
)
if isinstance(raw_ids, (list, tuple, set)):
for item in raw_ids:
try:
@@ -2141,8 +2199,7 @@ class WhatsAppClient(ClientBase):
or {}
)
sender = self._jid_to_identifier(
self._pluck(source, "Sender")
or self._pluck(source, "sender")
self._pluck(source, "Sender") or self._pluck(source, "sender")
)
chat = self._jid_to_identifier(
self._pluck(source, "Chat") or self._pluck(source, "chat")
@@ -2161,19 +2218,26 @@ class WhatsAppClient(ClientBase):
await self.ur.started_typing(
self.service,
identifier=candidate,
payload={"presence": state_text, "sender": str(sender), "chat": str(chat)},
payload={
"presence": state_text,
"sender": str(sender),
"chat": str(chat),
},
)
else:
await self.ur.stopped_typing(
await self.ur.stopped_typing(
self.service,
identifier=candidate,
payload={"presence": state_text, "sender": str(sender), "chat": str(chat)},
payload={
"presence": state_text,
"sender": str(sender),
"chat": str(chat),
},
)
async def _handle_presence_event(self, event):
sender = self._jid_to_identifier(
self._pluck(event, "From", "User")
or self._pluck(event, "from", "user")
self._pluck(event, "From", "User") or self._pluck(event, "from", "user")
)
is_unavailable = bool(
self._pluck(event, "Unavailable") or self._pluck(event, "unavailable")
@@ -2271,7 +2335,9 @@ class WhatsAppClient(ClientBase):
}
return None
async def send_message_raw(self, recipient, text=None, attachments=None):
async def send_message_raw(
self, recipient, text=None, attachments=None, command_id: str | None = None
):
if not self._client:
return False
if self._build_jid is None:
@@ -2283,8 +2349,12 @@ class WhatsAppClient(ClientBase):
try:
jid = self._build_jid(jid_str)
# Verify it's a proper JID object with SerializeToString method
if not hasattr(jid, 'SerializeToString'):
self.log.error("whatsapp build_jid returned non-JID object: type=%s repr=%s", type(jid).__name__, repr(jid)[:100])
if not hasattr(jid, "SerializeToString"):
self.log.error(
"whatsapp build_jid returned non-JID object: type=%s repr=%s",
type(jid).__name__,
repr(jid)[:100],
)
return False
except Exception as exc:
self.log.warning("whatsapp failed to build JID from %s: %s", jid_str, exc)
@@ -2313,7 +2383,9 @@ class WhatsAppClient(ClientBase):
payload = await self._fetch_attachment_payload(attachment)
if not payload:
continue
mime = str(payload.get("content_type") or "application/octet-stream").lower()
mime = str(
payload.get("content_type") or "application/octet-stream"
).lower()
data = payload.get("content") or b""
filename = payload.get("filename") or "attachment.bin"
@@ -2327,7 +2399,9 @@ class WhatsAppClient(ClientBase):
self._client.send_video(jid, data, caption="")
)
elif mime.startswith("audio/") and hasattr(self._client, "send_audio"):
response = await self._maybe_await(self._client.send_audio(jid, data))
response = await self._maybe_await(
self._client.send_audio(jid, data)
)
elif hasattr(self._client, "send_document"):
response = await self._maybe_await(
self._client.send_document(
@@ -2351,21 +2425,46 @@ class WhatsAppClient(ClientBase):
if text:
response = None
last_error = None
for attempt in range(3):
# Prepare cancel key (if caller provided command_id)
cancel_key = None
try:
if command_id:
cancel_key = transport._runtime_command_cancel_key(
self.service, str(command_id)
)
except Exception:
cancel_key = None
for attempt in range(5): # Increased from 3 to 5 attempts
# Check for a cancellation marker set by transport.cancel_runtime_command
try:
if cancel_key and cache.get(cancel_key):
self.log.info("whatsapp send cancelled via cancel marker")
return False
except Exception:
pass
try:
# Log what we're about to send for debugging
self.log.debug(f"send_message attempt {attempt+1}: jid={jid} text_type={type(text).__name__} text_len={len(text)}")
response = await self._maybe_await(self._client.send_message(jid, text))
if getattr(settings, "WHATSAPP_DEBUG", False):
self.log.debug(
f"send_message attempt {attempt+1}: jid={jid} text_type={type(text).__name__} text_len={len(text)}"
)
response = await self._maybe_await(
self._client.send_message(jid, text)
)
sent_any = True
last_error = None
break
except Exception as exc:
self.log.debug(f"send_message attempt {attempt+1} failed: {type(exc).__name__}: {exc}")
if getattr(settings, "WHATSAPP_DEBUG", False):
self.log.debug(
f"send_message attempt {attempt+1} failed: {type(exc).__name__}: {exc}"
)
last_error = exc
error_text = str(last_error or "").lower()
is_transient = "usync query" in error_text or "timed out" in error_text
if is_transient and attempt < 2:
if is_transient and attempt < 4: # Updated to match new attempt range
if hasattr(self._client, "connect"):
try:
await self._maybe_await(self._client.connect())
@@ -2381,7 +2480,21 @@ class WhatsAppClient(ClientBase):
last_event="send_retry_reconnect_failed",
last_error=str(reconnect_exc),
)
await asyncio.sleep(0.8 * (attempt + 1))
# Sleep but wake earlier if cancelled: poll small intervals
# Increase backoff time for device list queries
total_sleep = 1.5 * (attempt + 1)
slept = 0.0
while slept < total_sleep:
try:
if cancel_key and cache.get(cancel_key):
self.log.info(
"whatsapp send cancelled during retry backoff"
)
return False
except Exception:
pass
await asyncio.sleep(0.2)
slept += 0.2
continue
break
if last_error is not None and not sent_any:
@@ -2420,7 +2533,9 @@ class WhatsAppClient(ClientBase):
pass
if hasattr(self._client, "set_chat_presence"):
try:
await self._maybe_await(self._client.set_chat_presence(jid, "composing"))
await self._maybe_await(
self._client.set_chat_presence(jid, "composing")
)
return True
except Exception:
pass

View File

@@ -922,7 +922,9 @@ class XMPPComponent(ComponentXMPP):
}
)
if (not body or body.strip().lower() in {"[no body]", "(no text)"}) and attachments:
if (
not body or body.strip().lower() in {"[no body]", "(no text)"}
) and attachments:
attachment_urls = [
str(item.get("url") or "").strip()
for item in attachments

View File

@@ -3,7 +3,6 @@ from django.db.models import Q
from core.models import Message, MessageEvent
EMPTY_TEXT_VALUES = {
"",
"[No Body]",
@@ -105,9 +104,7 @@ class Command(BaseCommand):
queryset = Message.objects.filter(
sender_uuid__iexact="xmpp",
).filter(
Q(text__isnull=True) | Q(text__exact="") | Q(text__iexact="[No Body]")
)
).filter(Q(text__isnull=True) | Q(text__exact="") | Q(text__iexact="[No Body]"))
if user_id:
queryset = queryset.filter(user_id=user_id)
queryset = queryset.order_by("ts", "id")

View File

@@ -4,7 +4,6 @@ import time
from django.core.cache import cache
DEFAULT_BLOB_TTL_SECONDS = 60 * 20

View File

@@ -21,7 +21,9 @@ class UnifiedEvent:
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 []
raw_timestamps = (
payload.get("message_timestamps") or payload.get("timestamps") or []
)
if isinstance(raw_timestamps, list):
for item in raw_timestamps:
try:
@@ -44,7 +46,10 @@ def normalize_gateway_event(service: str, payload: dict[str, Any]) -> UnifiedEve
service=service,
event_type=event_type,
identifier=str(
payload.get("identifier") or payload.get("source") or payload.get("from") or ""
payload.get("identifier")
or payload.get("source")
or payload.get("from")
or ""
).strip(),
text=str(payload.get("text") or ""),
ts=ts,

View File

@@ -8,10 +8,7 @@ from django.core import signing
from core.models import ChatSession, Message, PersonIdentifier, WorkspaceConversation
from core.realtime.typing_state import get_person_typing_state
from core.views.compose import (
COMPOSE_WS_TOKEN_SALT,
_serialize_messages_with_artifacts,
)
from core.views.compose import COMPOSE_WS_TOKEN_SALT, _serialize_messages_with_artifacts
def _safe_int(value, default=0):

View File

@@ -25,9 +25,7 @@ def set_person_typing_state(
"source_service": str(source_service or ""),
"display_name": str(display_name or ""),
"updated_ts": now_ms,
"expires_ts": (
now_ms + (TYPING_TTL_SECONDS * 1000) if started else now_ms
),
"expires_ts": (now_ms + (TYPING_TTL_SECONDS * 1000) if started else now_ms),
}
cache.set(
_person_key(user_id, person_id),

View File

@@ -77,21 +77,21 @@
headers: { 'HX-Request': 'true' },
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed contacts preview fetch.');
}
return response.text();
})
if (!response.ok) {
throw new Error('Failed contacts preview fetch.');
}
return response.text();
})
.then((html) => {
composeDropdown.innerHTML = html;
composePreviewLoaded = true;
})
composeDropdown.innerHTML = html;
composePreviewLoaded = true;
})
.catch(() => {
composePreviewLoaded = false;
})
composePreviewLoaded = false;
})
.finally(() => {
composePreviewLoading = false;
});
composePreviewLoading = false;
});
});
}

View File

@@ -161,88 +161,88 @@
<div class="table-container">
<table id="discovered-contacts-table" class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th data-discovered-col="0" class="discovered-col-0">Person</th>
<th data-discovered-col="1" class="discovered-col-1">Name</th>
<th data-discovered-col="2" class="discovered-col-2">Service</th>
<th data-discovered-col="3" class="discovered-col-3">Identifier</th>
<th data-discovered-col="4" class="discovered-col-4">Suggest</th>
<th data-discovered-col="5" class="discovered-col-5">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for row in candidates %}
<tr
data-service="{{ row.service }}"
data-person="{{ row.linked_person_name|default:'-'|lower }}"
data-detected="{{ row.detected_name|default:'-'|lower }}"
data-identifier="{{ row.identifier|lower }}"
data-search="{{ row.linked_person_name|default:'-'|lower }} {{ row.detected_name|default:'-'|lower }} {{ row.service|lower }} {{ row.identifier|lower }}">
<td data-discovered-col="0" class="discovered-col-0">{{ row.linked_person_name|default:"-" }}</td>
<td data-discovered-col="1" class="discovered-col-1">{{ row.detected_name|default:"-" }}</td>
<td data-discovered-col="2" class="discovered-col-2">
{{ row.service|title }}
</td>
<td data-discovered-col="3" class="discovered-col-3"><code>{{ row.identifier }}</code></td>
<td data-discovered-col="4" class="discovered-col-4">
{% if not row.linked_person and row.suggestions %}
<div class="buttons are-small">
{% for suggestion in row.suggestions %}
<form method="post" style="display: inline-flex;">
{% csrf_token %}
<input type="hidden" name="service" value="{{ row.service }}">
<input type="hidden" name="identifier" value="{{ row.identifier }}">
<input type="hidden" name="person_id" value="{{ suggestion.person.id }}">
<button
type="submit"
class="button is-small is-success is-light is-rounded"
title="Accept suggested match">
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
<span>{{ suggestion.person.name }}</span>
</button>
</form>
{% endfor %}
</div>
{% else %}
<span class="has-text-grey">-</span>
{% endif %}
</td>
<td data-discovered-col="5" class="discovered-col-5">
{% if row.linked_person %}
<span class="tag is-success is-light">linked</span>
{% else %}
<thead>
<tr>
<th data-discovered-col="0" class="discovered-col-0">Person</th>
<th data-discovered-col="1" class="discovered-col-1">Name</th>
<th data-discovered-col="2" class="discovered-col-2">Service</th>
<th data-discovered-col="3" class="discovered-col-3">Identifier</th>
<th data-discovered-col="4" class="discovered-col-4">Suggest</th>
<th data-discovered-col="5" class="discovered-col-5">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for row in candidates %}
<tr
data-service="{{ row.service }}"
data-person="{{ row.linked_person_name|default:'-'|lower }}"
data-detected="{{ row.detected_name|default:'-'|lower }}"
data-identifier="{{ row.identifier|lower }}"
data-search="{{ row.linked_person_name|default:'-'|lower }} {{ row.detected_name|default:'-'|lower }} {{ row.service|lower }} {{ row.identifier|lower }}">
<td data-discovered-col="0" class="discovered-col-0">{{ row.linked_person_name|default:"-" }}</td>
<td data-discovered-col="1" class="discovered-col-1">{{ row.detected_name|default:"-" }}</td>
<td data-discovered-col="2" class="discovered-col-2">
{{ row.service|title }}
</td>
<td data-discovered-col="3" class="discovered-col-3"><code>{{ row.identifier }}</code></td>
<td data-discovered-col="4" class="discovered-col-4">
{% if not row.linked_person and row.suggestions %}
<div class="buttons are-small">
{% for suggestion in row.suggestions %}
<form method="post" style="display: inline-flex;">
{% csrf_token %}
<input type="hidden" name="service" value="{{ row.service }}">
<input type="hidden" name="identifier" value="{{ row.identifier }}">
<input type="hidden" name="person_id" value="{{ suggestion.person.id }}">
<button
type="submit"
class="button is-small is-success is-light is-rounded"
title="Accept suggested match">
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
<span>{{ suggestion.person.name }}</span>
</button>
</form>
{% endfor %}
</div>
{% else %}
<span class="has-text-grey">-</span>
{% endif %}
</td>
<td data-discovered-col="5" class="discovered-col-5">
{% if row.linked_person %}
<span class="tag is-success is-light">linked</span>
{% else %}
<button
type="button"
class="tag is-warning is-light js-unlinked-link"
data-service="{{ row.service }}"
data-identifier="{{ row.identifier }}"
title="Click to prefill link form">
unlinked
</button>
{% endif %}
</td>
<td>
<button
type="button"
class="tag is-warning is-light js-unlinked-link"
data-service="{{ row.service }}"
class="button is-small is-light js-contact-info"
data-service="{{ row.service|title }}"
data-identifier="{{ row.identifier }}"
title="Click to prefill link form">
unlinked
data-person="{{ row.linked_person_name|default:'-' }}"
data-detected="{{ row.detected_name|default:'-' }}"
data-status="{% if row.linked_person %}linked{% else %}unlinked{% endif %}"
data-suggested="{% if row.suggestions %}{% for suggestion in row.suggestions %}{{ suggestion.person.name }}{% if not forloop.last %}, {% endif %}{% endfor %}{% else %}-{% endif %}">
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
</button>
{% endif %}
</td>
<td>
<button
type="button"
class="button is-small is-light js-contact-info"
data-service="{{ row.service|title }}"
data-identifier="{{ row.identifier }}"
data-person="{{ row.linked_person_name|default:'-' }}"
data-detected="{{ row.detected_name|default:'-' }}"
data-status="{% if row.linked_person %}linked{% else %}unlinked{% endif %}"
data-suggested="{% if row.suggestions %}{% for suggestion in row.suggestions %}{{ suggestion.person.name }}{% if not forloop.last %}, {% endif %}{% endfor %}{% else %}-{% endif %}">
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
</button>
<a class="button is-small is-light" href="{{ row.compose_url }}">
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<a class="button is-small is-light" href="{{ row.compose_url }}">
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}

View File

@@ -399,8 +399,8 @@
return String(value || "")
.split(",")
.map(function (item) {
return item.trim();
})
return item.trim();
})
.filter(Boolean);
};

View File

@@ -536,8 +536,8 @@
showOperationPane(operation);
const activeTab = tabKey || (
operation === "artifacts"
? ((window.giaWorkspaceState[personId] || {}).currentMitigationTab || "plan_board")
: operation
? ((window.giaWorkspaceState[personId] || {}).currentMitigationTab || "plan_board")
: operation
);
setTopCapsuleActive(activeTab);
const hydrated = hydrateCachedIfAvailable(operation);
@@ -573,8 +573,8 @@
const currentState = window.giaWorkspaceState[personId] || {};
const targetTabKey = currentState.pendingTabKey || (
operation === "artifacts"
? (currentState.currentMitigationTab || "plan_board")
: operation
? (currentState.currentMitigationTab || "plan_board")
: operation
);
if (!forceRefresh && currentState.current === operation && pane.dataset.loaded === "1") {
window.giaWorkspaceShowTab(personId, operation, targetTabKey);
@@ -663,8 +663,8 @@
const state = window.giaWorkspaceState[personId] || {};
const currentTab = state.currentTab || (
state.current === "artifacts"
? (state.currentMitigationTab || "plan_board")
: (state.current || "plan_board")
? (state.currentMitigationTab || "plan_board")
: (state.current || "plan_board")
);
window.giaWorkspaceOpenTab(personId, currentTab, true);
};

View File

@@ -254,6 +254,9 @@
{% endwith %}
{% endif %}
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
<div class="compose-source-badge-wrap">
<span class="compose-source-badge source-{{ msg.source_service|default:'web'|lower }}">{{ msg.source_label }}</span>
</div>
{% if msg.image_urls %}
{% for image_url in msg.image_urls %}
<figure class="compose-media">
@@ -285,14 +288,30 @@
<p class="compose-msg-meta">
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
{% if msg.read_ts %}
<span class="compose-ticks" title="Read at {{ msg.read_display }}">
<span
class="compose-ticks js-receipt-trigger"
role="button"
tabindex="0"
data-receipt='{{ msg.receipt_payload|default:"{}"|escapejs }}'
data-source='{{ msg.read_source_service }}'
data-by='{{ msg.read_by_identifier }}'
data-id='{{ msg.id }}'
title="Read at {{ msg.read_display }}">
<span class="icon is-small"><i class="fa-solid fa-check-double has-text-info"></i></span>
<span class="compose-tick-time">{{ msg.read_display }}</span>
<span class="compose-tick-time">{{ msg.read_delta_display }}</span>
</span>
{% elif msg.delivered_ts %}
<span class="compose-ticks" title="Delivered at {{ msg.delivered_display }}">
<span
class="compose-ticks js-receipt-trigger"
role="button"
tabindex="0"
data-receipt='{{ msg.receipt_payload|default:"{}"|escapejs }}'
data-source='{{ msg.read_source_service }}'
data-by='{{ msg.read_by_identifier }}'
data-id='{{ msg.id }}'
title="Delivered at {{ msg.delivered_display }}">
<span class="icon is-small"><i class="fa-solid fa-check-double has-text-grey"></i></span>
<span class="compose-tick-time">{{ msg.delivered_display }}</span>
<span class="compose-tick-time">{{ msg.delivered_delta_display }}</span>
</span>
{% endif %}
</p>
@@ -438,6 +457,26 @@
padding: 0.52rem 0.62rem;
box-shadow: none;
}
#{{ panel_id }} .compose-source-badge-wrap {
display: flex;
justify-content: flex-start;
margin-bottom: 0.36rem;
}
#{{ panel_id }} .compose-source-badge {
font-size: 0.84rem;
padding: 0.12rem 0.5rem;
border-radius: 6px;
color: #fff;
font-weight: 800;
letter-spacing: 0.02em;
box-shadow: 0 1px 0 rgba(0,0,0,0.06);
}
#{{ panel_id }} .compose-source-badge.source-web { background: #2f4f7a; }
#{{ panel_id }} .compose-source-badge.source-xmpp { background: #6a88b4; }
#{{ panel_id }} .compose-source-badge.source-whatsapp { background: #25D366; color: #063; }
#{{ panel_id }} .compose-source-badge.source-signal { background: #3b82f6; }
#{{ panel_id }} .compose-source-badge.source-instagram { background: #c13584; }
#{{ panel_id }} .compose-source-badge.source-unknown { background: #6b7280; }
#{{ panel_id }} .compose-bubble.is-in {
background: rgba(255, 255, 255, 0.96);
}
@@ -1719,6 +1758,7 @@
};
const appendBubble = function (msg) {
console.log("[appendBubble]", {id: msg.id, ts: msg.ts, author: msg.author, source_label: msg.source_label, source_service: msg.source_service, outgoing: msg.outgoing});
const row = document.createElement("div");
const outgoing = !!msg.outgoing;
row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
@@ -1729,12 +1769,24 @@
const bubble = document.createElement("article");
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
// Add source badge for client-side rendered messages
if (msg.source_label) {
console.log("[appendBubble] rendering source badge:", msg.source_label);
const badgeWrap = document.createElement("div");
badgeWrap.className = "compose-source-badge-wrap";
const badge = document.createElement("span");
const svc = String(msg.source_service || "web").toLowerCase();
badge.className = "compose-source-badge source-" + svc;
badge.textContent = String(msg.source_label || "");
badgeWrap.appendChild(badge);
bubble.appendChild(badgeWrap);
}
const imageCandidatesFromPayload = Array.isArray(msg.image_urls) && msg.image_urls.length
? msg.image_urls
: (msg.image_url ? [msg.image_url] : []);
? msg.image_urls
: (msg.image_url ? [msg.image_url] : []);
const imageCandidates = imageCandidatesFromPayload.length
? imageCandidatesFromPayload
: extractUrlCandidates(msg.text || msg.display_text || "");
? imageCandidatesFromPayload
: extractUrlCandidates(msg.text || msg.display_text || "");
appendImageCandidates(bubble, imageCandidates);
if (!msg.hide_text) {
@@ -1759,44 +1811,55 @@
if (msg.author) {
metaText += " · " + String(msg.author);
}
meta.textContent = metaText;
// Render delivery/read ticks and a small time label when available.
if (msg.read_ts) {
const tickWrap = document.createElement("span");
tickWrap.className = "compose-ticks";
tickWrap.title = "Read at " + String(msg.read_display || msg.read_ts || "");
const icon = document.createElement("span");
icon.className = "icon is-small";
const i = document.createElement("i");
i.className = "fa-solid fa-check-double has-text-info";
icon.appendChild(i);
const timeSpan = document.createElement("span");
timeSpan.className = "compose-tick-time";
timeSpan.textContent = String(msg.read_display || "");
tickWrap.appendChild(icon);
tickWrap.appendChild(timeSpan);
meta.appendChild(document.createTextNode(" "));
meta.appendChild(tickWrap);
} else if (msg.delivered_ts) {
const tickWrap = document.createElement("span");
tickWrap.className = "compose-ticks";
tickWrap.title = "Delivered at " + String(msg.delivered_display || msg.delivered_ts || "");
const icon = document.createElement("span");
icon.className = "icon is-small";
const i = document.createElement("i");
i.className = "fa-solid fa-check-double has-text-grey";
icon.appendChild(i);
const timeSpan = document.createElement("span");
timeSpan.className = "compose-tick-time";
timeSpan.textContent = String(msg.delivered_display || "");
tickWrap.appendChild(icon);
tickWrap.appendChild(timeSpan);
meta.appendChild(document.createTextNode(" "));
meta.appendChild(tickWrap);
}
meta.textContent = metaText;
// Render delivery/read ticks and a small time label when available.
if (msg.read_ts) {
const tickWrap = document.createElement("span");
tickWrap.className = "compose-ticks";
tickWrap.title = "Read at " + String(msg.read_display || msg.read_ts || "");
const icon = document.createElement("span");
icon.className = "icon is-small";
const i = document.createElement("i");
i.className = "fa-solid fa-check-double has-text-info";
icon.appendChild(i);
const timeSpan = document.createElement("span");
timeSpan.className = "compose-tick-time";
timeSpan.textContent = String(msg.read_display || "");
tickWrap.appendChild(icon);
tickWrap.appendChild(timeSpan);
meta.appendChild(document.createTextNode(" "));
meta.appendChild(tickWrap);
} else if (msg.delivered_ts) {
const tickWrap = document.createElement("span");
tickWrap.className = "compose-ticks";
tickWrap.title = "Delivered at " + String(msg.delivered_display || msg.delivered_ts || "");
const icon = document.createElement("span");
icon.className = "icon is-small";
const i = document.createElement("i");
i.className = "fa-solid fa-check-double has-text-grey";
icon.appendChild(i);
const timeSpan = document.createElement("span");
timeSpan.className = "compose-tick-time";
timeSpan.textContent = String(msg.delivered_display || "");
tickWrap.appendChild(icon);
tickWrap.appendChild(timeSpan);
meta.appendChild(document.createTextNode(" "));
meta.appendChild(tickWrap);
}
bubble.appendChild(meta);
row.appendChild(bubble);
// If message carries receipt metadata, append dataset so the popover can use it.
if (msg.receipt_payload || msg.read_source_service || msg.read_by_identifier) {
// Attach data attributes on the row so event delegation can find them.
try {
row.dataset.receipt = JSON.stringify(msg.receipt_payload || {});
} catch (e) {
row.dataset.receipt = "{}";
}
row.dataset.receiptSource = String(msg.read_source_service || "");
row.dataset.receiptBy = String(msg.read_by_identifier || "");
row.dataset.receiptId = String(msg.id || "");
}
const empty = thread.querySelector(".compose-empty");
if (empty) {
empty.remove();
@@ -1810,6 +1873,87 @@
updateGlanceFromMessage(msg);
};
// Receipt popover (similar to contact info popover)
const receiptPopover = document.createElement("div");
receiptPopover.id = "compose-receipt-popover";
receiptPopover.className = "compose-ai-popover is-hidden";
receiptPopover.setAttribute("aria-hidden", "true");
receiptPopover.innerHTML = `
<div class="compose-ai-card is-active" style="min-width:18rem;">
<p class="compose-ai-title">Receipt Details</p>
<div class="compose-ai-content">
<table class="table is-fullwidth is-striped is-size-7"><tbody>
<tr><th>Message ID</th><td id="receipt-msg-id">-</td></tr>
<tr><th>Source</th><td id="receipt-source">-</td></tr>
<tr><th>Read By</th><td id="receipt-by">-</td></tr>
<tr><th>Delivered</th><td id="receipt-delivered">-</td></tr>
<tr><th>Read</th><td id="receipt-read">-</td></tr>
<tr><th>Payload</th><td><pre id="receipt-payload" style="white-space:pre-wrap;max-height:18rem;overflow:auto"></pre></td></tr>
</tbody></table>
</div>
</div>
`;
document.body.appendChild(receiptPopover);
let activeReceiptBtn = null;
function hideReceiptPopover() {
receiptPopover.classList.add("is-hidden");
receiptPopover.setAttribute("aria-hidden", "true");
activeReceiptBtn = null;
}
function positionReceiptPopover(btn) {
const rect = btn.getBoundingClientRect();
const width = Math.min(520, Math.max(280, Math.floor(window.innerWidth * 0.32)));
const left = Math.min(window.innerWidth - width - 16, Math.max(12, rect.left - width + rect.width));
const top = Math.min(window.innerHeight - 24, rect.bottom + 8);
receiptPopover.style.left = left + "px";
receiptPopover.style.top = top + "px";
receiptPopover.style.width = width + "px";
}
function openReceiptPopoverFromData(data, btn) {
document.getElementById("receipt-msg-id").textContent = data.id || "-";
document.getElementById("receipt-source").textContent = data.source || "-";
document.getElementById("receipt-by").textContent = data.by || "-";
document.getElementById("receipt-delivered").textContent = data.delivered || "-";
document.getElementById("receipt-read").textContent = data.read || "-";
try {
const out = typeof data.payload === 'string' ? JSON.parse(data.payload) : data.payload || {};
document.getElementById("receipt-payload").textContent = JSON.stringify(out, null, 2);
} catch (e) {
document.getElementById("receipt-payload").textContent = String(data.payload || "{}");
}
positionReceiptPopover(btn);
receiptPopover.classList.remove("is-hidden");
receiptPopover.setAttribute("aria-hidden", "false");
}
// Delegate click on tick triggers inside thread
thread.addEventListener("click", function (ev) {
const btn = ev.target.closest && ev.target.closest('.js-receipt-trigger');
if (!btn) return;
if (activeReceiptBtn === btn && !receiptPopover.classList.contains('is-hidden')) {
hideReceiptPopover();
return;
}
activeReceiptBtn = btn;
const payload = btn.dataset && btn.dataset.receipt ? btn.dataset.receipt : (btn.parentNode && btn.parentNode.dataset ? btn.parentNode.dataset.receipt : "{}");
const source = btn.dataset && btn.dataset.source ? btn.dataset.source : (btn.parentNode && btn.parentNode.dataset ? btn.parentNode.dataset.receiptSource : "");
const by = btn.dataset && btn.dataset.by ? btn.dataset.by : (btn.parentNode && btn.parentNode.dataset ? btn.parentNode.dataset.receiptBy : "");
const id = btn.dataset && btn.dataset.id ? btn.dataset.id : (btn.parentNode && btn.parentNode.dataset ? btn.parentNode.dataset.receiptId : "");
const delivered = btn.title || "";
const read = btn.title || "";
openReceiptPopoverFromData({ id: id, payload: payload, source: source, by: by, delivered: delivered, read: read }, btn);
});
// Close receipt popover on outside click / escape
document.addEventListener("click", function (ev) {
if (receiptPopover.classList.contains('is-hidden')) return;
if (receiptPopover.contains(ev.target)) return;
if (activeReceiptBtn && activeReceiptBtn.contains(ev.target)) return;
hideReceiptPopover();
});
document.addEventListener("keydown", function (ev) { if (ev.key === 'Escape') hideReceiptPopover(); });
const applyMinuteGrouping = function () {
const rows = Array.from(thread.querySelectorAll(".compose-row"));
rows.forEach(function (row) {
@@ -1889,15 +2033,18 @@
}
params.set("limit", thread.dataset.limit || "60");
params.set("after_ts", String(lastTs));
console.log("[poll] fetching messages: service=" + params.get("service") + " after_ts=" + lastTs);
const response = await fetch(thread.dataset.pollUrl + "?" + params.toString(), {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
});
if (!response.ok) {
console.log("[poll] response not ok:", response.status);
return;
}
const payload = await response.json();
console.log("[poll] received payload with " + (payload.messages ? payload.messages.length : 0) + " messages");
appendMessages(payload.messages || [], forceScroll);
if (payload.typing) {
applyTyping(payload.typing);
@@ -2522,7 +2669,7 @@
} catch (err) {
setCardLoading(card, false);
card.querySelector(".compose-ai-content").textContent =
"Failed to load quick insights.";
"Failed to load quick insights.";
}
};
@@ -2541,8 +2688,8 @@
const customText = card.querySelector(".engage-custom-text");
const selectedSource = (
preferredSource !== undefined
? preferredSource
: (sourceSelect ? sourceSelect.value : "")
? preferredSource
: (sourceSelect ? sourceSelect.value : "")
);
const customValue = customText ? String(customText.value || "").trim() : "";
const showCustom = selectedSource === "custom";
@@ -2734,8 +2881,8 @@
const selectedPerson = selected.dataset.person || thread.dataset.person || "";
const selectedPageUrl = (
renderMode === "page"
? selected.dataset.pageUrl
: selected.dataset.widgetUrl
? selected.dataset.pageUrl
: selected.dataset.widgetUrl
) || "";
switchThreadContext(
selectedService,
@@ -2764,8 +2911,8 @@
const selectedPerson = selected.dataset.person || "";
let selectedPageUrl = (
renderMode === "page"
? selected.dataset[servicePageUrlKey]
: selected.dataset[serviceWidgetUrlKey]
? selected.dataset[servicePageUrlKey]
: selected.dataset[serviceWidgetUrlKey]
) || "";
if (!selectedIdentifier) {
selectedService = selected.dataset.service || selectedService;
@@ -2774,8 +2921,8 @@
if (!selectedPageUrl) {
selectedPageUrl = (
renderMode === "page"
? selected.dataset.pageUrl
: selected.dataset.widgetUrl
? selected.dataset.pageUrl
: selected.dataset.widgetUrl
) || "";
}
switchThreadContext(
@@ -2877,6 +3024,51 @@
textarea.focus();
});
// Cancel send support: show a cancel button while the form request is pending.
let cancelBtn = null;
const showCancelButton = function () {
if (cancelBtn) return;
cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'button is-danger is-light is-small compose-cancel-send-btn';
cancelBtn.textContent = 'Cancel Send';
cancelBtn.addEventListener('click', function () {
// Post cancel by service+identifier
const payload = new URLSearchParams();
payload.set('service', thread.dataset.service || '');
payload.set('identifier', thread.dataset.identifier || '');
fetch('{% url "compose_cancel_send" %}', {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-CSRFToken': '{{ csrf_token }}', 'Content-Type': 'application/x-www-form-urlencoded' },
body: payload.toString(),
}).then(function (resp) {
// Hide cancel once requested
hideCancelButton();
}).catch(function () {
hideCancelButton();
});
});
if (statusBox) {
statusBox.appendChild(cancelBtn);
}
};
const hideCancelButton = function () {
if (!cancelBtn) return;
try { cancelBtn.remove(); } catch (e) {}
cancelBtn = null;
};
// Show cancel on submit; htmx will make the request asynchronously.
form.addEventListener('submit', function (ev) {
// Only show when send confirmation allows
if (sendButton && sendButton.disabled) return;
showCancelButton();
});
// Hide cancel after HTMX request completes
form.addEventListener('htmx:afterRequest', function () { hideCancelButton(); });
panelState.eventHandler = function (event) {
const detail = (event && event.detail) || {};
const sourcePanelId = String(detail.panel_id || "");
@@ -2887,6 +3079,114 @@
};
document.body.addEventListener("composeMessageSent", panelState.eventHandler);
// Persistent queued-command handling: when server returns composeSendCommandId
// HTMX will dispatch a `composeSendCommandId` event with detail {command_id: "..."}.
panelState.pendingCommandId = null;
panelState.pendingCommandPoll = null;
const startPendingCommandPolling = function (commandId) {
if (!commandId) return;
panelState.pendingCommandId = commandId;
// Show persistent cancel UI
showPersistentCancelButton(commandId);
// Poll for result every 1500ms
if (panelState.pendingCommandPoll) {
clearInterval(panelState.pendingCommandPoll);
}
panelState.pendingCommandPoll = setInterval(async function () {
try {
const url = new URL('{% url "compose_command_result" %}', window.location.origin);
url.searchParams.set('service', thread.dataset.service || '');
url.searchParams.set('command_id', commandId);
const resp = await fetch(url.toString(), { credentials: 'same-origin' });
if (!resp.ok) return;
const payload = await resp.json();
if (payload && payload.pending === false) {
// Stop polling
stopPendingCommandPolling();
// Hide cancel UI
hidePersistentCancelButton();
// Surface result to the user
const result = payload.result || {};
if (result.ok) {
setStatus('', 'success');
textarea.value = '';
autosize();
flashCompose('is-send-success');
poll(true);
} else {
const msg = String(result.error || 'Send failed.');
setStatus(msg, 'danger');
flashCompose('is-send-fail');
poll(true);
}
}
} catch (e) {
// ignore transient network errors
}
}, 1500);
};
const stopPendingCommandPolling = function () {
if (panelState.pendingCommandPoll) {
clearInterval(panelState.pendingCommandPoll);
panelState.pendingCommandPoll = null;
}
panelState.pendingCommandId = null;
};
const persistentCancelContainerId = panelId + '-persistent-cancel';
const showPersistentCancelButton = function (commandId) {
hidePersistentCancelButton();
const container = document.createElement('div');
container.id = persistentCancelContainerId;
container.style.marginTop = '0.35rem';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'button is-danger is-light is-small compose-persistent-cancel-btn';
btn.textContent = 'Cancel Queued Send';
btn.addEventListener('click', function () {
const payload = new URLSearchParams();
payload.set('service', thread.dataset.service || '');
payload.set('identifier', thread.dataset.identifier || '');
payload.set('command_id', String(commandId || ''));
fetch('{% url "compose_cancel_send" %}', {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-CSRFToken': '{{ csrf_token }}', 'Content-Type': 'application/x-www-form-urlencoded' },
body: payload.toString(),
}).then(function (resp) {
stopPendingCommandPolling();
hidePersistentCancelButton();
setStatus('Send cancelled.', 'warning');
poll(true);
}).catch(function () {
hidePersistentCancelButton();
});
});
container.appendChild(btn);
if (statusBox) {
statusBox.appendChild(container);
}
};
const hidePersistentCancelButton = function () {
try {
const el = document.getElementById(persistentCancelContainerId);
if (el) el.remove();
} catch (e) {}
};
document.body.addEventListener('composeSendCommandId', function (ev) {
try {
const detail = (ev && ev.detail) || {};
const cmd = (detail && detail.command_id) || (detail && detail.composeSendCommandId && detail.composeSendCommandId.command_id) || null;
if (cmd) {
startPendingCommandPolling(String(cmd));
}
} catch (e) {}
});
panelState.sendResultHandler = function (event) {
const detail = (event && event.detail) || {};
const sourcePanelId = String(detail.panel_id || "");

View File

@@ -4,8 +4,9 @@ import hashlib
import json
import re
import time
from datetime import datetime
from datetime import timezone as dt_timezone
from difflib import SequenceMatcher
from datetime import datetime, timezone as dt_timezone
from urllib.parse import quote_plus, urlencode, urlparse
from asgiref.sync import async_to_sync
@@ -40,7 +41,11 @@ from core.models import (
WorkspaceConversation,
)
from core.realtime.typing_state import get_person_typing_state
from core.views.workspace import INSIGHT_METRICS, _build_engage_payload, _parse_draft_options
from core.views.workspace import (
INSIGHT_METRICS,
_build_engage_payload,
_parse_draft_options,
)
COMPOSE_WS_TOKEN_SALT = "compose-ws"
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
@@ -129,7 +134,9 @@ def _extract_urls(text_value: str) -> list[str]:
def _is_url_only_text(text_value: str) -> bool:
lines = [line.strip() for line in str(text_value or "").splitlines() if line.strip()]
lines = [
line.strip() for line in str(text_value or "").splitlines() if line.strip()
]
if not lines:
return False
return all(bool(URL_PATTERN.fullmatch(line)) for line in lines)
@@ -150,10 +157,14 @@ def _is_xmpp_share_url(url_value: str) -> bool:
return False
parsed = urlparse(url_value)
host = str(parsed.netloc or "").strip().lower()
configured = str(
getattr(settings, "XMPP_UPLOAD_SERVICE", "")
or getattr(settings, "XMPP_UPLOAD_JID", "")
).strip().lower()
configured = (
str(
getattr(settings, "XMPP_UPLOAD_SERVICE", "")
or getattr(settings, "XMPP_UPLOAD_JID", "")
)
.strip()
.lower()
)
if not configured:
return False
configured_host = configured
@@ -200,15 +211,21 @@ def _extract_attachment_image_urls(blob) -> list[str]:
return urls
if isinstance(blob, dict):
content_type = str(
blob.get("content_type")
or blob.get("contentType")
or blob.get("mime_type")
or blob.get("mimetype")
or ""
).strip().lower()
content_type = (
str(
blob.get("content_type")
or blob.get("contentType")
or blob.get("mime_type")
or blob.get("mimetype")
or ""
)
.strip()
.lower()
)
filename = str(blob.get("filename") or blob.get("fileName") or "").strip()
image_hint = content_type.startswith("image/") or _looks_like_image_name(filename)
image_hint = content_type.startswith("image/") or _looks_like_image_name(
filename
)
direct_urls = []
for key in ("url", "source_url", "download_url", "proxy_url", "href", "uri"):
@@ -264,7 +281,9 @@ def _attachment_image_urls_by_message(messages):
).order_by("ts")
for event in linked_events:
legacy_id = str((event.raw_payload_ref or {}).get("legacy_message_id") or "").strip()
legacy_id = str(
(event.raw_payload_ref or {}).get("legacy_message_id") or ""
).strip()
if not legacy_id:
continue
urls = _uniq_ordered(
@@ -296,9 +315,7 @@ def _attachment_image_urls_by_message(messages):
continue
msg_ts = int(msg.ts or 0)
candidates = [
event
for event in fallback_list
if abs(int(event.ts or 0) - msg_ts) <= 3000
event for event in fallback_list if abs(int(event.ts or 0) - msg_ts) <= 3000
]
if not candidates:
continue
@@ -322,8 +339,51 @@ def _serialize_message(msg: Message) -> dict:
and _is_url_only_text(text_value)
and all(_looks_like_image_url(url) for url in image_urls)
)
display_text = text_value if text_value.strip() else ("(no text)" if not image_url else "")
display_text = (
text_value if text_value.strip() else ("(no text)" if not image_url else "")
)
author = str(msg.custom_author or "").strip()
is_outgoing = _is_outgoing(msg)
# Determine source service for display: prefer explicit session identifier service
source_service = "web"
try:
if getattr(msg, "session", None) and getattr(msg.session, "identifier", None):
svc = str(msg.session.identifier.service or "").strip().lower()
if svc:
source_service = svc
except Exception:
pass
from core.util import logs as util_logs
logger = util_logs.get_logger("compose")
logger.info(
f"[serialize_message] id={msg.id} author={author} is_outgoing={is_outgoing} source_service={source_service}"
)
# For outgoing messages sent from web UI, label as "Web Chat".
# For incoming messages, use the session's service name (Xmpp, Signal, Whatsapp, etc).
# But if source_service is still "web" and message is incoming, it may be a data issue—
# don't label it as "Web Chat" since that's misleading.
if is_outgoing:
source_label = "Web Chat"
else:
# Incoming message: use service-specific labels
service_labels = {
"xmpp": "XMPP",
"whatsapp": "WhatsApp",
"signal": "Signal",
"instagram": "Instagram",
"web": "External", # Fallback if service not identified
}
source_label = service_labels.get(
source_service, source_service.title() if source_service else "Unknown"
)
# Ensure source_label is never empty for UI rendering
if not source_label:
source_label = "Unknown"
delivered_ts = int(msg.delivered_ts or 0)
read_ts = int(msg.read_ts or 0)
delivered_display = _format_ts_label(int(delivered_ts)) if delivered_ts else ""
@@ -331,6 +391,17 @@ def _serialize_message(msg: Message) -> dict:
ts_val = int(msg.ts or 0)
delivered_delta = int(delivered_ts - ts_val) if delivered_ts and ts_val else None
read_delta = int(read_ts - ts_val) if read_ts and ts_val else None
# Human friendly delta strings
delivered_delta_display = (
_format_gap_duration(delivered_delta) if delivered_delta is not None else ""
)
read_delta_display = (
_format_gap_duration(read_delta) if read_delta is not None else ""
)
# Receipt payload and metadata
receipt_payload = msg.receipt_payload or {}
read_source_service = str(msg.read_source_service or "").strip()
read_by_identifier = str(msg.read_by_identifier or "").strip()
return {
"id": str(msg.id),
@@ -343,12 +414,19 @@ def _serialize_message(msg: Message) -> dict:
"hide_text": hide_text,
"author": author,
"outgoing": _is_outgoing(msg),
"source_service": source_service,
"source_label": source_label,
"delivered_ts": delivered_ts,
"read_ts": read_ts,
"delivered_display": delivered_display,
"read_display": read_display,
"delivered_delta": delivered_delta,
"read_delta": read_delta,
"delivered_delta_display": delivered_delta_display,
"read_delta_display": read_delta_display,
"receipt_payload": receipt_payload,
"read_source_service": read_source_service,
"read_by_identifier": read_by_identifier,
}
@@ -510,9 +588,8 @@ def _workspace_conversation_for_person(user, person):
def _counterpart_identifiers_for_person(user, person):
if person is None:
return set()
values = (
PersonIdentifier.objects.filter(user=user, person=person)
.values_list("identifier", flat=True)
values = PersonIdentifier.objects.filter(user=user, person=person).values_list(
"identifier", flat=True
)
return {str(value or "").strip() for value in values if str(value or "").strip()}
@@ -598,13 +675,17 @@ def _build_thread_metric_fragments(conversation):
def _build_gap_fragment(is_outgoing_reply, lag_ms, snapshot):
metric_slug = "outbound_response_score" if is_outgoing_reply else "inbound_response_score"
metric_slug = (
"outbound_response_score" if is_outgoing_reply else "inbound_response_score"
)
copy = _metric_copy(metric_slug, "Response Score")
score_value = None
if snapshot is not None:
score_value = getattr(
snapshot,
"outbound_response_score" if is_outgoing_reply else "inbound_response_score",
"outbound_response_score"
if is_outgoing_reply
else "inbound_response_score",
None,
)
if score_value is None:
@@ -651,7 +732,9 @@ def _serialize_messages_with_artifacts(
item["metric_fragments"] = []
counterpart_identifiers = set(counterpart_identifiers or [])
snapshot = conversation.metric_snapshots.first() if conversation is not None else None
snapshot = (
conversation.metric_snapshots.first() if conversation is not None else None
)
prev_msg = seed_previous
prev_ts = int(prev_msg.ts or 0) if prev_msg is not None else None
@@ -663,7 +746,9 @@ def _serialize_messages_with_artifacts(
for idx, msg in enumerate(rows):
current_ts = int(msg.ts or 0)
current_outgoing = _message_is_outgoing_for_analysis(msg, counterpart_identifiers)
current_outgoing = _message_is_outgoing_for_analysis(
msg, counterpart_identifiers
)
if (
prev_msg is not None
and prev_ts is not None
@@ -680,7 +765,9 @@ def _serialize_messages_with_artifacts(
prev_outgoing = current_outgoing
if serialized:
serialized[-1]["metric_fragments"] = _build_thread_metric_fragments(conversation)
serialized[-1]["metric_fragments"] = _build_thread_metric_fragments(
conversation
)
return serialized
@@ -770,12 +857,7 @@ def _build_glance_items(serialized_messages, person_id=None):
def _owner_name(user) -> str:
return (
user.first_name
or user.get_full_name().strip()
or user.username
or "Me"
)
return user.first_name or user.get_full_name().strip() or user.username or "Me"
def _compose_ws_token(user_id, service, identifier, person_id):
@@ -789,7 +871,9 @@ def _compose_ws_token(user_id, service, identifier, person_id):
return signing.dumps(payload, salt=COMPOSE_WS_TOKEN_SALT)
def _compose_ai_cache_key(kind, user_id, service, identifier, person_id, last_ts, limit):
def _compose_ai_cache_key(
kind, user_id, service, identifier, person_id, last_ts, limit
):
raw = "|".join(
[
str(kind or ""),
@@ -825,7 +909,9 @@ def _engage_body_only(value):
def _messages_for_ai(user, person_identifier, limit):
if person_identifier is None:
return []
session, _ = ChatSession.objects.get_or_create(user=user, identifier=person_identifier)
session, _ = ChatSession.objects.get_or_create(
user=user, identifier=person_identifier
)
rows = list(
Message.objects.filter(user=user, session=session)
.select_related("session", "session__identifier", "session__identifier__person")
@@ -949,7 +1035,9 @@ def _trend_meta(current, previous, higher_is_better=True):
improves = is_up if higher_is_better else not is_up
return {
"direction": "up" if is_up else "down",
"icon": "fa-solid fa-arrow-trend-up" if is_up else "fa-solid fa-arrow-trend-down",
"icon": "fa-solid fa-arrow-trend-up"
if is_up
else "fa-solid fa-arrow-trend-down",
"class_name": "has-text-success" if improves else "has-text-danger",
"meaning": "Improving signal" if improves else "Risk signal",
}
@@ -1443,7 +1531,9 @@ def _manual_contact_rows(user):
if key in seen:
return
seen.add(key)
urls = _compose_urls(service_key, identifier_value, person.id if person else None)
urls = _compose_urls(
service_key, identifier_value, person.id if person else None
)
linked_person_name = person.name if person else ""
detected = _clean_detected_name(detected_name or account or "")
person_name = linked_person_name or detected or identifier_value
@@ -1502,7 +1592,9 @@ def _manual_contact_rows(user):
person=(linked.person if linked else None),
source="signal_chat",
account=str(chat.account or ""),
detected_name=_clean_detected_name(chat.source_name or chat.account or ""),
detected_name=_clean_detected_name(
chat.source_name or chat.account or ""
),
)
whatsapp_links = {
@@ -1529,7 +1621,9 @@ def _manual_contact_rows(user):
continue
if _normalize_contact_key(candidate) in wa_account_keys:
continue
detected_name = _clean_detected_name(item.get("name") or item.get("chat") or "")
detected_name = _clean_detected_name(
item.get("name") or item.get("chat") or ""
)
if detected_name.lower() == "linked account":
continue
linked = whatsapp_links.get(candidate)
@@ -1572,7 +1666,10 @@ def _recent_manual_contacts(
current_person_id = str(current_person.id) if current_person else ""
row_by_key = {
(str(row.get("service") or "").strip().lower(), str(row.get("identifier") or "").strip()): row
(
str(row.get("service") or "").strip().lower(),
str(row.get("identifier") or "").strip(),
): row
for row in all_rows
}
by_person_service = {}
@@ -1716,8 +1813,12 @@ def _recent_manual_contacts(
seen_unknown.add(unknown_key)
row["service_label"] = _service_label(service_key)
for svc in ("signal", "whatsapp", "instagram", "xmpp"):
row[f"{svc}_identifier"] = identifier_value if svc == service_key else ""
row[f"{svc}_compose_url"] = row.get("compose_url") if svc == service_key else ""
row[f"{svc}_identifier"] = (
identifier_value if svc == service_key else ""
)
row[f"{svc}_compose_url"] = (
row.get("compose_url") if svc == service_key else ""
)
row[f"{svc}_compose_widget_url"] = (
row.get("compose_widget_url") if svc == service_key else ""
)
@@ -1855,7 +1956,9 @@ def _panel_context(
for service_key in sorted(by_service.keys(), key=_service_order):
identifier_value = by_service[service_key]
option_urls = _compose_urls(service_key, identifier_value, base["person"].id)
option_urls = _compose_urls(
service_key, identifier_value, base["person"].id
)
platform_options.append(
{
"service": service_key,
@@ -2122,7 +2225,9 @@ class ComposeContactMatch(LoginRequiredMixin, View):
row.save(update_fields=["person"])
message = f"Re-linked {identifier} ({service}) to {person.name}."
else:
message = f"{identifier} ({service}) is already linked to {person.name}."
message = (
f"{identifier} ({service}) is already linked to {person.name}."
)
linked_companions = 0
skipped_companions = 0
@@ -2247,7 +2352,8 @@ class ComposeThread(LoginRequiredMixin, View):
user=request.user,
identifier=base["person_identifier"],
)
session_ids = list({*session_ids, int(session.id)})
# Don't convert UUIDs to int; keep them as UUIDs for the filter query
session_ids = list({*session_ids, session.id})
if session_ids:
base_queryset = Message.objects.filter(
user=request.user,
@@ -2264,8 +2370,7 @@ class ComposeThread(LoginRequiredMixin, View):
"session",
"session__identifier",
"session__identifier__person",
)
.order_by("ts")[:limit]
).order_by("ts")[:limit]
)
newest = (
Message.objects.filter(
@@ -2328,6 +2433,7 @@ class ComposeHistorySync(LoginRequiredMixin, View):
values.add(local)
return [value for value in values if value]
@classmethod
@classmethod
def _session_ids_for_scope(
cls,
@@ -2370,12 +2476,13 @@ class ComposeHistorySync(LoginRequiredMixin, View):
unique_ids.append(row_id)
if not unique_ids:
return []
return list(
result = list(
ChatSession.objects.filter(
user=user,
identifier_id__in=unique_ids,
).values_list("id", flat=True)
)
return result
@staticmethod
def _reconcile_duplicate_messages(user, session_ids):
@@ -2417,7 +2524,11 @@ class ComposeHistorySync(LoginRequiredMixin, View):
person = get_object_or_404(Person, id=person_id, user=request.user)
if not identifier and person is None:
return JsonResponse(
{"ok": False, "message": "Missing contact identifier.", "level": "danger"}
{
"ok": False,
"message": "Missing contact identifier.",
"level": "danger",
}
)
base = _context_base(request.user, service, identifier, person)
@@ -2575,6 +2686,44 @@ class ComposeHistorySync(LoginRequiredMixin, View):
)
class ComposeCancelSend(LoginRequiredMixin, View):
def post(self, request):
service = _default_service(request.POST.get("service"))
identifier = str(request.POST.get("identifier") or "").strip()
command_id = str(request.POST.get("command_id") or "").strip()
if not identifier:
return JsonResponse({"ok": False, "error": "missing_identifier"})
# If a specific command_id is supplied, cancel that command only.
if command_id:
ok = transport.cancel_runtime_command(service, command_id)
return JsonResponse({"ok": True, "cancelled": [command_id] if ok else []})
cancelled = transport.cancel_runtime_commands_for_recipient(service, identifier)
return JsonResponse({"ok": True, "cancelled": cancelled})
class ComposeCommandResult(LoginRequiredMixin, View):
"""Return the runtime command result for a queued send (if available).
GET parameters: `service`, `command_id`.
Returns JSON: if pending -> {"pending": True}, else returns the result dict.
"""
def get(self, request):
service = _default_service(request.GET.get("service"))
command_id = str(request.GET.get("command_id") or "").strip()
if not command_id:
return JsonResponse(
{"ok": False, "error": "missing_command_id"}, status=400
)
# Non-blocking check for runtime command result
result = async_to_sync(transport.wait_runtime_command_result)(
service, command_id, timeout=0.1
)
if result is None:
return JsonResponse({"pending": True})
return JsonResponse({"pending": False, "result": result})
class ComposeMediaBlob(LoginRequiredMixin, View):
"""
Serve cached media blobs for authenticated compose image previews.
@@ -2773,21 +2922,23 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
"thread": "",
"last_event": "",
"last_ai_run": "",
"workspace_created": "",
"snapshot_count": 0,
"platform_docs": _metric_copy("platform", "Platform"),
"state_docs": _metric_copy("stability_state", "Participant State"),
"thread_docs": _metric_copy("thread", "Thread"),
"snapshot_docs": {
"calculation": (
"Count of stored workspace metric snapshots for this person."
),
"psychology": (
"More points improve trend reliability; sparse points are "
"best treated as directional signals."
"workspace_created": "",
"snapshot_count": 0,
"platform_docs": _metric_copy("platform", "Platform"),
"state_docs": _metric_copy(
"stability_state", "Participant State"
),
"thread_docs": _metric_copy("thread", "Thread"),
"snapshot_docs": {
"calculation": (
"Count of stored workspace metric snapshots for this person."
),
"psychology": (
"More points improve trend reliability; sparse points are "
"best treated as directional signals."
),
},
},
},
"rows": [],
"docs": [
"Quick Insights needs at least one workspace conversation snapshot.",
@@ -2935,7 +3086,9 @@ class ComposeEngagePreview(LoginRequiredMixin, View):
)
preview = str(payload.get("preview") or "").strip()
outbound = _engage_body_only(payload.get("outbound") or "")
artifact_label = f"{source_kind.title()}: {getattr(source_obj, 'title', '')}"
artifact_label = (
f"{source_kind.title()}: {getattr(source_obj, 'title', '')}"
)
else:
ai_obj = AI.objects.filter(user=request.user).first()
if ai_obj is not None:
@@ -3062,6 +3215,11 @@ class ComposeSend(LoginRequiredMixin, View):
"panel_id": str(panel_id or ""),
}
}
# Optional: include command id to allow client-side cancellation UI
if hasattr(request, "_compose_command_id") and request._compose_command_id:
trigger_payload["composeSendCommandId"] = {
"command_id": str(request._compose_command_id)
}
if ok:
trigger_payload["composeMessageSent"] = {"panel_id": str(panel_id or "")}
response["HX-Trigger"] = json.dumps(trigger_payload)
@@ -3104,12 +3262,48 @@ class ComposeSend(LoginRequiredMixin, View):
)
base = _context_base(request.user, service, identifier, person)
ts = async_to_sync(transport.send_message_raw)(
base["service"],
base["identifier"],
text=text,
attachments=[],
from core.util import logs as util_logs
logger = util_logs.get_logger("compose")
log_prefix = (
f"[ComposeSend] service={base['service']} identifier={base['identifier']}"
)
logger.info(f"{log_prefix} text_len={len(text)} attempting send")
# If runtime is out-of-process, enqueue command and return immediately (non-blocking).
# Expose command id for cancellation so the client can cancel or poll later.
runtime_client = transport.get_runtime_client(base["service"]) or None
logger.info(
f"{log_prefix} runtime_client={type(runtime_client).__name__ if runtime_client else 'None (queued)'}"
)
ts = None
command_id = None
if runtime_client is None:
logger.info(f"{log_prefix} enqueuing runtime command (out-of-process)")
command_id = transport.enqueue_runtime_command(
base["service"],
"send_message_raw",
{"recipient": base["identifier"], "text": text, "attachments": []},
)
logger.info(
f"{log_prefix} command_id={command_id} enqueued, returning immediately"
)
# attach command id to request so _response can include it in HX-Trigger
request._compose_command_id = command_id
# Do NOT wait here — return immediately so the UI doesn't block.
# Record a pending message locally so the thread shows the outgoing message.
ts = int(time.time() * 1000)
else:
# In-process runtime can perform the send synchronously and return a timestamp.
logger.info(f"{log_prefix} calling in-process send_message_raw (blocking)")
ts = async_to_sync(transport.send_message_raw)(
base["service"],
base["identifier"],
text=text,
attachments=[],
)
logger.info(f"{log_prefix} in-process send returned ts={ts}")
# For queued sends we set `ts` to a local timestamp; for in-process sends ts may be False.
if not ts:
return self._response(
request,
@@ -3124,15 +3318,34 @@ class ComposeSend(LoginRequiredMixin, View):
user=request.user,
identifier=base["person_identifier"],
)
Message.objects.create(
logger.info(f"{log_prefix} session_id={session.id}")
# For in-process sends (Signal, etc), ts is a timestamp or True.
# For queued sends (WhatsApp/UR), ts is a local timestamp.
# Set delivered_ts only if we got a real timestamp OR if it's an in-process sync send.
msg_ts = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
delivered_ts = msg_ts if runtime_client is not None else None
msg = 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,
ts=msg_ts,
delivered_ts=delivered_ts,
custom_author="USER",
)
logger.info(
f"{log_prefix} created message id={msg.id} ts={msg_ts} delivered_ts={delivered_ts} custom_author=USER"
)
# If we enqueued, inform the client the message is queued and include command id.
if runtime_client is None:
return self._response(
request,
ok=True,
message="Message queued for sending.",
level="info",
panel_id=panel_id,
)
return self._response(
request,

View File

@@ -3,8 +3,8 @@ from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import GroupForm
from core.models import Group
from core.views.osint import OSINTListBase
from core.util import logs
from core.views.osint import OSINTListBase
log = logs.get_logger(__name__)

View File

@@ -3,8 +3,8 @@ from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import ManipulationForm
from core.models import Manipulation
from core.views.osint import OSINTListBase
from core.util import logs
from core.views.osint import OSINTListBase
log = logs.get_logger(__name__)

View File

@@ -17,7 +17,7 @@ from django.urls import reverse
from django.views import View
from mixins.views import ObjectList
from core.models import Group, Manipulation, Persona, Person
from core.models import Group, Manipulation, Person, Persona
def _context_type(request_type: str) -> str:
@@ -82,9 +82,7 @@ def _url_with_query(base_url: str, query: dict[str, Any]) -> str:
return f"{base_url}?{urlencode(params)}"
def _merge_query(
current_query: dict[str, Any], **updates: Any
) -> dict[str, Any]:
def _merge_query(current_query: dict[str, Any], **updates: Any) -> dict[str, Any]:
merged = dict(current_query)
for key, value in updates.items():
if value is None or str(value).strip() == "":
@@ -695,9 +693,7 @@ class OSINTSearch(LoginRequiredMixin, View):
per_page_default = 20
per_page_max = 100
def _field_options(
self, model_cls: type[models.Model]
) -> list[dict[str, str]]:
def _field_options(self, model_cls: type[models.Model]) -> list[dict[str, str]]:
options = []
for field in model_cls._meta.get_fields():
# Skip reverse/accessor relations (e.g. ManyToManyRel) that are not
@@ -768,16 +764,18 @@ class OSINTSearch(LoginRequiredMixin, View):
if isinstance(field, models.ForeignKey):
related_text_field = _preferred_related_text_field(field.related_model)
if related_text_field:
return Q(
**{f"{field_name}__{related_text_field}__icontains": query}
), False
return (
Q(**{f"{field_name}__{related_text_field}__icontains": query}),
False,
)
return Q(**{f"{field_name}__id__icontains": query}), False
if isinstance(field, models.ManyToManyField):
related_text_field = _preferred_related_text_field(field.related_model)
if related_text_field:
return Q(
**{f"{field_name}__{related_text_field}__icontains": query}
), True
return (
Q(**{f"{field_name}__{related_text_field}__icontains": query}),
True,
)
return Q(**{f"{field_name}__id__icontains": query}), True
return None, False

View File

@@ -3,8 +3,8 @@ from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import PersonForm
from core.models import Person
from core.views.osint import OSINTListBase
from core.util import logs
from core.views.osint import OSINTListBase
log = logs.get_logger(__name__)

View File

@@ -3,8 +3,8 @@ from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import PersonaForm
from core.models import Persona
from core.views.osint import OSINTListBase
from core.util import logs
from core.views.osint import OSINTListBase
log = logs.get_logger(__name__)

View File

@@ -1,9 +1,10 @@
from urllib.parse import urlencode
import orjson
import requests
from django.conf import settings
from django.shortcuts import render
from django.urls import reverse
from urllib.parse import urlencode
from django.views import View
from mixins.views import ObjectList, ObjectRead

View File

@@ -19,8 +19,8 @@ from core.models import (
PatternMitigationPlan,
PatternMitigationRule,
Person,
PersonIdentifier,
Persona,
PersonIdentifier,
QueuedMessage,
WorkspaceConversation,
WorkspaceMetricSnapshot,
@@ -37,7 +37,9 @@ class SystemSettings(SuperUserRequiredMixin, View):
"messages": Message.objects.filter(user=user).count(),
"queued_messages": QueuedMessage.objects.filter(user=user).count(),
"message_events": MessageEvent.objects.filter(user=user).count(),
"workspace_conversations": WorkspaceConversation.objects.filter(user=user).count(),
"workspace_conversations": WorkspaceConversation.objects.filter(
user=user
).count(),
"workspace_snapshots": WorkspaceMetricSnapshot.objects.filter(
conversation__user=user
).count(),
@@ -57,7 +59,9 @@ class SystemSettings(SuperUserRequiredMixin, View):
"mitigation_auto_settings": PatternMitigationAutoSettings.objects.filter(
user=user
).count(),
"mitigation_exports": PatternArtifactExport.objects.filter(user=user).count(),
"mitigation_exports": PatternArtifactExport.objects.filter(
user=user
).count(),
"osint_people": Person.objects.filter(user=user).count(),
"osint_identifiers": PersonIdentifier.objects.filter(user=user).count(),
"osint_groups": Group.objects.filter(user=user).count(),
@@ -77,7 +81,9 @@ class SystemSettings(SuperUserRequiredMixin, View):
deleted += AIResult.objects.filter(user=user).delete()[0]
deleted += AIRequest.objects.filter(user=user).delete()[0]
deleted += MemoryItem.objects.filter(user=user).delete()[0]
deleted += WorkspaceMetricSnapshot.objects.filter(conversation__user=user).delete()[0]
deleted += WorkspaceMetricSnapshot.objects.filter(
conversation__user=user
).delete()[0]
deleted += MessageEvent.objects.filter(user=user).delete()[0]
deleted += Message.objects.filter(user=user).delete()[0]
deleted += QueuedMessage.objects.filter(user=user).delete()[0]

View File

@@ -1,15 +1,16 @@
import time
from urllib.parse import urlencode
from django.shortcuts import render
from django.urls import reverse
from urllib.parse import urlencode
from django.views import View
from mixins.views import ObjectList, ObjectRead
from core.clients import transport
from core.models import ChatSession, Message, PersonIdentifier
from core.util import logs
from core.views.compose import _compose_urls, _service_icon_class
from core.views.manage.permissions import SuperUserRequiredMixin
from core.util import logs
import time
log = logs.get_logger("whatsapp_view")
@@ -32,16 +33,13 @@ class WhatsApp(SuperUserRequiredMixin, View):
)
def delete(self, request, *args, **kwargs):
account = (
str(request.GET.get("account") or "").strip()
or next(
(
str(item or "").strip()
for item in transport.list_accounts("whatsapp")
if str(item or "").strip()
),
"",
)
account = str(request.GET.get("account") or "").strip() or next(
(
str(item or "").strip()
for item in transport.list_accounts("whatsapp")
if str(item or "").strip()
),
"",
)
if account:
transport.unlink_account("whatsapp", account)
@@ -381,9 +379,7 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
def _detail_context(self, kwargs, obj):
detail_url_args = {
arg: kwargs[arg]
for arg in self.detail_url_args
if arg in kwargs
arg: kwargs[arg] for arg in self.detail_url_args if arg in kwargs
}
return {
"object": obj,
@@ -410,7 +406,9 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
sqlite_scanned = int(state.get("history_sqlite_scanned") or 0)
on_demand_requested = bool(state.get("history_on_demand_requested"))
on_demand_error = str(state.get("history_on_demand_error") or "").strip() or "-"
on_demand_anchor = str(state.get("history_on_demand_anchor") or "").strip() or "-"
on_demand_anchor = (
str(state.get("history_on_demand_anchor") or "").strip() or "-"
)
history_running = bool(state.get("history_sync_running"))
return [
f"connected={bool(state.get('connected'))}",

View File

@@ -147,8 +147,7 @@ INSIGHT_METRICS = {
"group": "stability",
"history_field": "stability_score",
"calculation": (
"0.35*reciprocity + 0.25*continuity + 0.20*response + "
"0.20*volatility."
"0.35*reciprocity + 0.25*continuity + 0.20*response + " "0.20*volatility."
),
"psychology": (
"Higher values suggest consistent mutual engagement patterns; falling "
@@ -176,9 +175,7 @@ INSIGHT_METRICS = {
"100 * min(1, distinct_sample_days / span_days). Higher means steadier "
"day-to-day continuity."
),
"psychology": (
"Drops can signal communication becoming episodic or reactive."
),
"psychology": ("Drops can signal communication becoming episodic or reactive."),
},
"response_score": {
"title": "Response Component",
@@ -232,8 +229,7 @@ INSIGHT_METRICS = {
"history_field": "stability_sample_days",
"calculation": "Count of distinct calendar days represented in the sample.",
"psychology": (
"Coverage across days better captures rhythm, not just intensity "
"bursts."
"Coverage across days better captures rhythm, not just intensity " "bursts."
),
},
"stability_computed": {
@@ -250,9 +246,7 @@ INSIGHT_METRICS = {
"title": "Commit In",
"group": "commitment",
"history_field": "commitment_inbound_score",
"calculation": (
"0.60*inbound_response_score + 0.40*inbound_balance_score."
),
"calculation": ("0.60*inbound_response_score + 0.40*inbound_balance_score."),
"psychology": (
"Estimates counterpart follow-through and reciprocity toward the user."
),
@@ -261,9 +255,7 @@ INSIGHT_METRICS = {
"title": "Commit Out",
"group": "commitment",
"history_field": "commitment_outbound_score",
"calculation": (
"0.60*outbound_response_score + 0.40*outbound_balance_score."
),
"calculation": ("0.60*outbound_response_score + 0.40*outbound_balance_score."),
"psychology": (
"Estimates user follow-through and consistency toward the counterpart."
),
@@ -931,16 +923,22 @@ def _metric_psychological_read(metric_slug, conversation):
if score is None:
return "Calibrating: collect more interaction data before interpreting."
if score >= 70:
return "Pattern suggests low relational friction and resilient repair cycles."
return (
"Pattern suggests low relational friction and resilient repair cycles."
)
if score >= 50:
return "Pattern suggests moderate strain; monitor for repeated escalation loops."
return "Pattern suggests high friction risk; prioritise safety and repair pacing."
return (
"Pattern suggests high friction risk; prioritise safety and repair pacing."
)
if metric_slug == "stability_confidence":
conf = conversation.stability_confidence or 0.0
if conf < 0.25:
return "Low certainty: treat this as a weak signal, not a conclusion."
if conf < 0.6:
return "Moderate certainty: useful directional cue, still context-dependent."
return (
"Moderate certainty: useful directional cue, still context-dependent."
)
return "High certainty: trend interpretation is likely reliable."
if metric_slug in {"commitment_inbound", "commitment_outbound"}:
inbound = conversation.commitment_inbound_score
@@ -3119,7 +3117,7 @@ def _ai_detect_violations(user, plan, person, recent_rows, metric_context=None):
"clarification": "proactive correction mapped to an artifact",
"severity": "low|medium|high",
}
]
],
},
}
prompt = [
@@ -3673,7 +3671,9 @@ class AIWorkspaceInformation(LoginRequiredMixin, View):
latest_snapshot = conversation.metric_snapshots.first()
directionality = _commitment_directionality_payload(conversation)
commitment_graph_cards = [
card for card in _all_graph_payload(conversation) if card["group"] == "commitment"
card
for card in _all_graph_payload(conversation)
if card["group"] == "commitment"
]
graph_refs = []