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

@@ -209,7 +209,7 @@ DEBUG_TOOLBAR_PANELS = [
"debug_toolbar.panels.logging.LoggingPanel", "debug_toolbar.panels.logging.LoggingPanel",
"debug_toolbar.panels.redirects.RedirectsPanel", "debug_toolbar.panels.redirects.RedirectsPanel",
"debug_toolbar.panels.profiling.ProfilingPanel", "debug_toolbar.panels.profiling.ProfilingPanel",
"cachalot.panels.CachalotPanel", # "cachalot.panels.CachalotPanel", # Disabled due to compatibility issues with debug toolbar
] ]
from app.local_settings import * # noqa from app.local_settings import * # noqa

View File

@@ -160,6 +160,16 @@ urlpatterns = [
compose.ComposeSend.as_view(), compose.ComposeSend.as_view(),
name="compose_send", name="compose_send",
), ),
path(
"compose/cancel-send/",
compose.ComposeCancelSend.as_view(),
name="compose_cancel_send",
),
path(
"compose/command-result/",
compose.ComposeCommandResult.as_view(),
name="compose_command_result",
),
path( path(
"compose/drafts/", "compose/drafts/",
compose.ComposeDrafts.as_view(), compose.ComposeDrafts.as_view(),

View File

@@ -14,4 +14,3 @@ from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
application = get_wsgi_application() application = get_wsgi_application()

View File

@@ -7,8 +7,8 @@ from django.conf import settings
from core.clients import ClientBase, transport from core.clients import ClientBase, transport
from core.messaging import history from core.messaging import history
from core.modules.mixed_protocol import normalize_gateway_event
from core.models import PersonIdentifier from core.models import PersonIdentifier
from core.modules.mixed_protocol import normalize_gateway_event
class GatewayClient(ClientBase): class GatewayClient(ClientBase):
@@ -48,7 +48,9 @@ class GatewayClient(ClientBase):
self.log.info("%s gateway disabled by settings", self.service) self.log.info("%s gateway disabled by settings", self.service)
return return
if self._task is None: 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()) self._task = self.loop.create_task(self._poll_loop())
async def start_typing(self, identifier): 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 from core.clients.transport import * # noqa: F401,F403

View File

@@ -456,7 +456,9 @@ class HandleMessage(Command):
if session_key in session_cache: if session_key in session_cache:
chat_session = session_cache[session_key] chat_session = session_cache[session_key]
else: 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 session_cache[session_key] = chat_session
sender_key = source_uuid or source_number or identifier_candidates[0] sender_key = source_uuid or source_number or identifier_candidates[0]
message_key = (chat_session.id, ts, sender_key) message_key = (chat_session.id, ts, sender_key)

View File

@@ -3,8 +3,8 @@ import base64
import io import io
import secrets import secrets
import time import time
from urllib.parse import quote_plus
from typing import Any from typing import Any
from urllib.parse import quote_plus
import aiohttp import aiohttp
import orjson 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}" 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: def _gateway_base(service: str) -> str:
key = f"{service.upper()}_HTTP_URL" key = f"{service.upper()}_HTTP_URL"
default = f"http://{service}:8080" default = f"http://{service}:8080"
@@ -88,7 +92,9 @@ def update_runtime_state(service: str, **updates):
return state 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) service_key = _service_key(service)
command_id = secrets.token_hex(12) command_id = secrets.token_hex(12)
command = { command = {
@@ -118,7 +124,9 @@ def pop_runtime_command(service: str) -> dict[str, Any] | None:
return command 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) service_key = _service_key(service)
result_key = _runtime_command_result_key(service_key, command_id) result_key = _runtime_command_result_key(service_key, command_id)
payload = dict(result or {}) 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) 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) service_key = _service_key(service)
result_key = _runtime_command_result_key(service_key, command_id) result_key = _runtime_command_result_key(service_key, command_id)
deadline = time.monotonic() + max(0.1, float(timeout or 0.0)) deadline = time.monotonic() + max(0.1, float(timeout or 0.0))
@@ -149,7 +186,9 @@ def list_accounts(service: str):
if service_key == "signal": if service_key == "signal":
import requests 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: try:
response = requests.get(f"{base}/v1/accounts", timeout=20) response = requests.get(f"{base}/v1/accounts", timeout=20)
if not response.ok: if not response.ok:
@@ -199,7 +238,9 @@ def unlink_account(service: str, account: str) -> bool:
if service_key == "signal": if service_key == "signal":
import requests 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) target = quote_plus(account_value)
for path in (f"/v1/accounts/{target}", f"/v1/account/{target}"): for path in (f"/v1/accounts/{target}", f"/v1/account/{target}"):
try: try:
@@ -242,7 +283,9 @@ def unlink_account(service: str, account: str) -> bool:
connected=bool(accounts), connected=bool(accounts),
pair_status=("connected" if accounts else ""), pair_status=("connected" if accounts else ""),
pair_qr="", 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_event="account_unlinked",
last_error="", last_error="",
) )
@@ -619,7 +662,9 @@ def get_link_qr(service: str, device_name: str):
if service_key == "signal": if service_key == "signal":
import requests 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( response = requests.get(
f"{base}/v1/qrcodelink", f"{base}/v1/qrcodelink",
params={"device_name": device}, params={"device_name": device},

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import time
from django.core.cache import cache from django.core.cache import cache
DEFAULT_BLOB_TTL_SECONDS = 60 * 20 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: def normalize_gateway_event(service: str, payload: dict[str, Any]) -> UnifiedEvent:
event_type = str(payload.get("type") or "").strip().lower() event_type = str(payload.get("type") or "").strip().lower()
message_timestamps = [] 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): if isinstance(raw_timestamps, list):
for item in raw_timestamps: for item in raw_timestamps:
try: try:
@@ -44,7 +46,10 @@ def normalize_gateway_event(service: str, payload: dict[str, Any]) -> UnifiedEve
service=service, service=service,
event_type=event_type, event_type=event_type,
identifier=str( 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(), ).strip(),
text=str(payload.get("text") or ""), text=str(payload.get("text") or ""),
ts=ts, ts=ts,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -254,6 +254,9 @@
{% endwith %} {% endwith %}
{% endif %} {% endif %}
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% 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 %} {% if msg.image_urls %}
{% for image_url in msg.image_urls %} {% for image_url in msg.image_urls %}
<figure class="compose-media"> <figure class="compose-media">
@@ -285,14 +288,30 @@
<p class="compose-msg-meta"> <p class="compose-msg-meta">
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %} {{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
{% if msg.read_ts %} {% 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="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> </span>
{% elif msg.delivered_ts %} {% 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="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> </span>
{% endif %} {% endif %}
</p> </p>
@@ -438,6 +457,26 @@
padding: 0.52rem 0.62rem; padding: 0.52rem 0.62rem;
box-shadow: none; 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 { #{{ panel_id }} .compose-bubble.is-in {
background: rgba(255, 255, 255, 0.96); background: rgba(255, 255, 255, 0.96);
} }
@@ -1719,6 +1758,7 @@
}; };
const appendBubble = function (msg) { 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 row = document.createElement("div");
const outgoing = !!msg.outgoing; const outgoing = !!msg.outgoing;
row.className = "compose-row " + (outgoing ? "is-out" : "is-in"); row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
@@ -1729,12 +1769,24 @@
const bubble = document.createElement("article"); const bubble = document.createElement("article");
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in"); 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 const imageCandidatesFromPayload = Array.isArray(msg.image_urls) && msg.image_urls.length
? msg.image_urls ? msg.image_urls
: (msg.image_url ? [msg.image_url] : []); : (msg.image_url ? [msg.image_url] : []);
const imageCandidates = imageCandidatesFromPayload.length const imageCandidates = imageCandidatesFromPayload.length
? imageCandidatesFromPayload ? imageCandidatesFromPayload
: extractUrlCandidates(msg.text || msg.display_text || ""); : extractUrlCandidates(msg.text || msg.display_text || "");
appendImageCandidates(bubble, imageCandidates); appendImageCandidates(bubble, imageCandidates);
if (!msg.hide_text) { if (!msg.hide_text) {
@@ -1759,44 +1811,55 @@
if (msg.author) { if (msg.author) {
metaText += " · " + String(msg.author); metaText += " · " + String(msg.author);
} }
meta.textContent = metaText; meta.textContent = metaText;
// Render delivery/read ticks and a small time label when available. // Render delivery/read ticks and a small time label when available.
if (msg.read_ts) { if (msg.read_ts) {
const tickWrap = document.createElement("span"); const tickWrap = document.createElement("span");
tickWrap.className = "compose-ticks"; tickWrap.className = "compose-ticks";
tickWrap.title = "Read at " + String(msg.read_display || msg.read_ts || ""); tickWrap.title = "Read at " + String(msg.read_display || msg.read_ts || "");
const icon = document.createElement("span"); const icon = document.createElement("span");
icon.className = "icon is-small"; icon.className = "icon is-small";
const i = document.createElement("i"); const i = document.createElement("i");
i.className = "fa-solid fa-check-double has-text-info"; i.className = "fa-solid fa-check-double has-text-info";
icon.appendChild(i); icon.appendChild(i);
const timeSpan = document.createElement("span"); const timeSpan = document.createElement("span");
timeSpan.className = "compose-tick-time"; timeSpan.className = "compose-tick-time";
timeSpan.textContent = String(msg.read_display || ""); timeSpan.textContent = String(msg.read_display || "");
tickWrap.appendChild(icon); tickWrap.appendChild(icon);
tickWrap.appendChild(timeSpan); tickWrap.appendChild(timeSpan);
meta.appendChild(document.createTextNode(" ")); meta.appendChild(document.createTextNode(" "));
meta.appendChild(tickWrap); meta.appendChild(tickWrap);
} else if (msg.delivered_ts) { } else if (msg.delivered_ts) {
const tickWrap = document.createElement("span"); const tickWrap = document.createElement("span");
tickWrap.className = "compose-ticks"; tickWrap.className = "compose-ticks";
tickWrap.title = "Delivered at " + String(msg.delivered_display || msg.delivered_ts || ""); tickWrap.title = "Delivered at " + String(msg.delivered_display || msg.delivered_ts || "");
const icon = document.createElement("span"); const icon = document.createElement("span");
icon.className = "icon is-small"; icon.className = "icon is-small";
const i = document.createElement("i"); const i = document.createElement("i");
i.className = "fa-solid fa-check-double has-text-grey"; i.className = "fa-solid fa-check-double has-text-grey";
icon.appendChild(i); icon.appendChild(i);
const timeSpan = document.createElement("span"); const timeSpan = document.createElement("span");
timeSpan.className = "compose-tick-time"; timeSpan.className = "compose-tick-time";
timeSpan.textContent = String(msg.delivered_display || ""); timeSpan.textContent = String(msg.delivered_display || "");
tickWrap.appendChild(icon); tickWrap.appendChild(icon);
tickWrap.appendChild(timeSpan); tickWrap.appendChild(timeSpan);
meta.appendChild(document.createTextNode(" ")); meta.appendChild(document.createTextNode(" "));
meta.appendChild(tickWrap); meta.appendChild(tickWrap);
} }
bubble.appendChild(meta); 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"); const empty = thread.querySelector(".compose-empty");
if (empty) { if (empty) {
empty.remove(); empty.remove();
@@ -1810,6 +1873,87 @@
updateGlanceFromMessage(msg); 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 applyMinuteGrouping = function () {
const rows = Array.from(thread.querySelectorAll(".compose-row")); const rows = Array.from(thread.querySelectorAll(".compose-row"));
rows.forEach(function (row) { rows.forEach(function (row) {
@@ -1889,15 +2033,18 @@
} }
params.set("limit", thread.dataset.limit || "60"); params.set("limit", thread.dataset.limit || "60");
params.set("after_ts", String(lastTs)); 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(), { const response = await fetch(thread.dataset.pollUrl + "?" + params.toString(), {
method: "GET", method: "GET",
credentials: "same-origin", credentials: "same-origin",
headers: { Accept: "application/json" } headers: { Accept: "application/json" }
}); });
if (!response.ok) { if (!response.ok) {
console.log("[poll] response not ok:", response.status);
return; return;
} }
const payload = await response.json(); const payload = await response.json();
console.log("[poll] received payload with " + (payload.messages ? payload.messages.length : 0) + " messages");
appendMessages(payload.messages || [], forceScroll); appendMessages(payload.messages || [], forceScroll);
if (payload.typing) { if (payload.typing) {
applyTyping(payload.typing); applyTyping(payload.typing);
@@ -2522,7 +2669,7 @@
} catch (err) { } catch (err) {
setCardLoading(card, false); setCardLoading(card, false);
card.querySelector(".compose-ai-content").textContent = 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 customText = card.querySelector(".engage-custom-text");
const selectedSource = ( const selectedSource = (
preferredSource !== undefined preferredSource !== undefined
? preferredSource ? preferredSource
: (sourceSelect ? sourceSelect.value : "") : (sourceSelect ? sourceSelect.value : "")
); );
const customValue = customText ? String(customText.value || "").trim() : ""; const customValue = customText ? String(customText.value || "").trim() : "";
const showCustom = selectedSource === "custom"; const showCustom = selectedSource === "custom";
@@ -2734,8 +2881,8 @@
const selectedPerson = selected.dataset.person || thread.dataset.person || ""; const selectedPerson = selected.dataset.person || thread.dataset.person || "";
const selectedPageUrl = ( const selectedPageUrl = (
renderMode === "page" renderMode === "page"
? selected.dataset.pageUrl ? selected.dataset.pageUrl
: selected.dataset.widgetUrl : selected.dataset.widgetUrl
) || ""; ) || "";
switchThreadContext( switchThreadContext(
selectedService, selectedService,
@@ -2764,8 +2911,8 @@
const selectedPerson = selected.dataset.person || ""; const selectedPerson = selected.dataset.person || "";
let selectedPageUrl = ( let selectedPageUrl = (
renderMode === "page" renderMode === "page"
? selected.dataset[servicePageUrlKey] ? selected.dataset[servicePageUrlKey]
: selected.dataset[serviceWidgetUrlKey] : selected.dataset[serviceWidgetUrlKey]
) || ""; ) || "";
if (!selectedIdentifier) { if (!selectedIdentifier) {
selectedService = selected.dataset.service || selectedService; selectedService = selected.dataset.service || selectedService;
@@ -2774,8 +2921,8 @@
if (!selectedPageUrl) { if (!selectedPageUrl) {
selectedPageUrl = ( selectedPageUrl = (
renderMode === "page" renderMode === "page"
? selected.dataset.pageUrl ? selected.dataset.pageUrl
: selected.dataset.widgetUrl : selected.dataset.widgetUrl
) || ""; ) || "";
} }
switchThreadContext( switchThreadContext(
@@ -2877,6 +3024,51 @@
textarea.focus(); 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) { panelState.eventHandler = function (event) {
const detail = (event && event.detail) || {}; const detail = (event && event.detail) || {};
const sourcePanelId = String(detail.panel_id || ""); const sourcePanelId = String(detail.panel_id || "");
@@ -2887,6 +3079,114 @@
}; };
document.body.addEventListener("composeMessageSent", panelState.eventHandler); 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) { panelState.sendResultHandler = function (event) {
const detail = (event && event.detail) || {}; const detail = (event && event.detail) || {};
const sourcePanelId = String(detail.panel_id || ""); const sourcePanelId = String(detail.panel_id || "");

View File

@@ -4,8 +4,9 @@ import hashlib
import json import json
import re import re
import time import time
from datetime import datetime
from datetime import timezone as dt_timezone
from difflib import SequenceMatcher from difflib import SequenceMatcher
from datetime import datetime, timezone as dt_timezone
from urllib.parse import quote_plus, urlencode, urlparse from urllib.parse import quote_plus, urlencode, urlparse
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
@@ -40,7 +41,11 @@ from core.models import (
WorkspaceConversation, WorkspaceConversation,
) )
from core.realtime.typing_state import get_person_typing_state 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_WS_TOKEN_SALT = "compose-ws"
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage" 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: 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: if not lines:
return False return False
return all(bool(URL_PATTERN.fullmatch(line)) for line in lines) 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 return False
parsed = urlparse(url_value) parsed = urlparse(url_value)
host = str(parsed.netloc or "").strip().lower() host = str(parsed.netloc or "").strip().lower()
configured = str( configured = (
getattr(settings, "XMPP_UPLOAD_SERVICE", "") str(
or getattr(settings, "XMPP_UPLOAD_JID", "") getattr(settings, "XMPP_UPLOAD_SERVICE", "")
).strip().lower() or getattr(settings, "XMPP_UPLOAD_JID", "")
)
.strip()
.lower()
)
if not configured: if not configured:
return False return False
configured_host = configured configured_host = configured
@@ -200,15 +211,21 @@ def _extract_attachment_image_urls(blob) -> list[str]:
return urls return urls
if isinstance(blob, dict): if isinstance(blob, dict):
content_type = str( content_type = (
blob.get("content_type") str(
or blob.get("contentType") blob.get("content_type")
or blob.get("mime_type") or blob.get("contentType")
or blob.get("mimetype") or blob.get("mime_type")
or "" or blob.get("mimetype")
).strip().lower() or ""
)
.strip()
.lower()
)
filename = str(blob.get("filename") or blob.get("fileName") or "").strip() 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 = [] direct_urls = []
for key in ("url", "source_url", "download_url", "proxy_url", "href", "uri"): 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") ).order_by("ts")
for event in linked_events: 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: if not legacy_id:
continue continue
urls = _uniq_ordered( urls = _uniq_ordered(
@@ -296,9 +315,7 @@ def _attachment_image_urls_by_message(messages):
continue continue
msg_ts = int(msg.ts or 0) msg_ts = int(msg.ts or 0)
candidates = [ candidates = [
event event for event in fallback_list if abs(int(event.ts or 0) - msg_ts) <= 3000
for event in fallback_list
if abs(int(event.ts or 0) - msg_ts) <= 3000
] ]
if not candidates: if not candidates:
continue continue
@@ -322,8 +339,51 @@ def _serialize_message(msg: Message) -> dict:
and _is_url_only_text(text_value) and _is_url_only_text(text_value)
and all(_looks_like_image_url(url) for url in image_urls) 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() 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) delivered_ts = int(msg.delivered_ts or 0)
read_ts = int(msg.read_ts or 0) read_ts = int(msg.read_ts or 0)
delivered_display = _format_ts_label(int(delivered_ts)) if delivered_ts else "" 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) ts_val = int(msg.ts or 0)
delivered_delta = int(delivered_ts - ts_val) if delivered_ts and ts_val else None 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 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 { return {
"id": str(msg.id), "id": str(msg.id),
@@ -343,12 +414,19 @@ def _serialize_message(msg: Message) -> dict:
"hide_text": hide_text, "hide_text": hide_text,
"author": author, "author": author,
"outgoing": _is_outgoing(msg), "outgoing": _is_outgoing(msg),
"source_service": source_service,
"source_label": source_label,
"delivered_ts": delivered_ts, "delivered_ts": delivered_ts,
"read_ts": read_ts, "read_ts": read_ts,
"delivered_display": delivered_display, "delivered_display": delivered_display,
"read_display": read_display, "read_display": read_display,
"delivered_delta": delivered_delta, "delivered_delta": delivered_delta,
"read_delta": read_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): def _counterpart_identifiers_for_person(user, person):
if person is None: if person is None:
return set() return set()
values = ( values = PersonIdentifier.objects.filter(user=user, person=person).values_list(
PersonIdentifier.objects.filter(user=user, person=person) "identifier", flat=True
.values_list("identifier", flat=True)
) )
return {str(value or "").strip() for value in values if str(value or "").strip()} 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): 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") copy = _metric_copy(metric_slug, "Response Score")
score_value = None score_value = None
if snapshot is not None: if snapshot is not None:
score_value = getattr( score_value = getattr(
snapshot, snapshot,
"outbound_response_score" if is_outgoing_reply else "inbound_response_score", "outbound_response_score"
if is_outgoing_reply
else "inbound_response_score",
None, None,
) )
if score_value is None: if score_value is None:
@@ -651,7 +732,9 @@ def _serialize_messages_with_artifacts(
item["metric_fragments"] = [] item["metric_fragments"] = []
counterpart_identifiers = set(counterpart_identifiers or []) 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_msg = seed_previous
prev_ts = int(prev_msg.ts or 0) if prev_msg is not None else None 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): for idx, msg in enumerate(rows):
current_ts = int(msg.ts or 0) 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 ( if (
prev_msg is not None prev_msg is not None
and prev_ts is not None and prev_ts is not None
@@ -680,7 +765,9 @@ def _serialize_messages_with_artifacts(
prev_outgoing = current_outgoing prev_outgoing = current_outgoing
if serialized: if serialized:
serialized[-1]["metric_fragments"] = _build_thread_metric_fragments(conversation) serialized[-1]["metric_fragments"] = _build_thread_metric_fragments(
conversation
)
return serialized return serialized
@@ -770,12 +857,7 @@ def _build_glance_items(serialized_messages, person_id=None):
def _owner_name(user) -> str: def _owner_name(user) -> str:
return ( return user.first_name or user.get_full_name().strip() or user.username or "Me"
user.first_name
or user.get_full_name().strip()
or user.username
or "Me"
)
def _compose_ws_token(user_id, service, identifier, person_id): 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) 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( raw = "|".join(
[ [
str(kind or ""), str(kind or ""),
@@ -825,7 +909,9 @@ def _engage_body_only(value):
def _messages_for_ai(user, person_identifier, limit): def _messages_for_ai(user, person_identifier, limit):
if person_identifier is None: if person_identifier is None:
return [] 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( rows = list(
Message.objects.filter(user=user, session=session) Message.objects.filter(user=user, session=session)
.select_related("session", "session__identifier", "session__identifier__person") .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 improves = is_up if higher_is_better else not is_up
return { return {
"direction": "up" if is_up else "down", "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", "class_name": "has-text-success" if improves else "has-text-danger",
"meaning": "Improving signal" if improves else "Risk signal", "meaning": "Improving signal" if improves else "Risk signal",
} }
@@ -1443,7 +1531,9 @@ def _manual_contact_rows(user):
if key in seen: if key in seen:
return return
seen.add(key) 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 "" linked_person_name = person.name if person else ""
detected = _clean_detected_name(detected_name or account or "") detected = _clean_detected_name(detected_name or account or "")
person_name = linked_person_name or detected or identifier_value 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), person=(linked.person if linked else None),
source="signal_chat", source="signal_chat",
account=str(chat.account or ""), 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 = { whatsapp_links = {
@@ -1529,7 +1621,9 @@ def _manual_contact_rows(user):
continue continue
if _normalize_contact_key(candidate) in wa_account_keys: if _normalize_contact_key(candidate) in wa_account_keys:
continue 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": if detected_name.lower() == "linked account":
continue continue
linked = whatsapp_links.get(candidate) linked = whatsapp_links.get(candidate)
@@ -1572,7 +1666,10 @@ def _recent_manual_contacts(
current_person_id = str(current_person.id) if current_person else "" current_person_id = str(current_person.id) if current_person else ""
row_by_key = { 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 for row in all_rows
} }
by_person_service = {} by_person_service = {}
@@ -1716,8 +1813,12 @@ def _recent_manual_contacts(
seen_unknown.add(unknown_key) seen_unknown.add(unknown_key)
row["service_label"] = _service_label(service_key) row["service_label"] = _service_label(service_key)
for svc in ("signal", "whatsapp", "instagram", "xmpp"): for svc in ("signal", "whatsapp", "instagram", "xmpp"):
row[f"{svc}_identifier"] = identifier_value if svc == service_key else "" row[f"{svc}_identifier"] = (
row[f"{svc}_compose_url"] = row.get("compose_url") if svc == service_key else "" 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[f"{svc}_compose_widget_url"] = (
row.get("compose_widget_url") if svc == service_key else "" 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): for service_key in sorted(by_service.keys(), key=_service_order):
identifier_value = by_service[service_key] 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( platform_options.append(
{ {
"service": service_key, "service": service_key,
@@ -2122,7 +2225,9 @@ class ComposeContactMatch(LoginRequiredMixin, View):
row.save(update_fields=["person"]) row.save(update_fields=["person"])
message = f"Re-linked {identifier} ({service}) to {person.name}." message = f"Re-linked {identifier} ({service}) to {person.name}."
else: else:
message = f"{identifier} ({service}) is already linked to {person.name}." message = (
f"{identifier} ({service}) is already linked to {person.name}."
)
linked_companions = 0 linked_companions = 0
skipped_companions = 0 skipped_companions = 0
@@ -2247,7 +2352,8 @@ class ComposeThread(LoginRequiredMixin, View):
user=request.user, user=request.user,
identifier=base["person_identifier"], 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: if session_ids:
base_queryset = Message.objects.filter( base_queryset = Message.objects.filter(
user=request.user, user=request.user,
@@ -2264,8 +2370,7 @@ class ComposeThread(LoginRequiredMixin, View):
"session", "session",
"session__identifier", "session__identifier",
"session__identifier__person", "session__identifier__person",
) ).order_by("ts")[:limit]
.order_by("ts")[:limit]
) )
newest = ( newest = (
Message.objects.filter( Message.objects.filter(
@@ -2328,6 +2433,7 @@ class ComposeHistorySync(LoginRequiredMixin, View):
values.add(local) values.add(local)
return [value for value in values if value] return [value for value in values if value]
@classmethod
@classmethod @classmethod
def _session_ids_for_scope( def _session_ids_for_scope(
cls, cls,
@@ -2370,12 +2476,13 @@ class ComposeHistorySync(LoginRequiredMixin, View):
unique_ids.append(row_id) unique_ids.append(row_id)
if not unique_ids: if not unique_ids:
return [] return []
return list( result = list(
ChatSession.objects.filter( ChatSession.objects.filter(
user=user, user=user,
identifier_id__in=unique_ids, identifier_id__in=unique_ids,
).values_list("id", flat=True) ).values_list("id", flat=True)
) )
return result
@staticmethod @staticmethod
def _reconcile_duplicate_messages(user, session_ids): 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) person = get_object_or_404(Person, id=person_id, user=request.user)
if not identifier and person is None: if not identifier and person is None:
return JsonResponse( 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) 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): class ComposeMediaBlob(LoginRequiredMixin, View):
""" """
Serve cached media blobs for authenticated compose image previews. Serve cached media blobs for authenticated compose image previews.
@@ -2773,21 +2922,23 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
"thread": "", "thread": "",
"last_event": "", "last_event": "",
"last_ai_run": "", "last_ai_run": "",
"workspace_created": "", "workspace_created": "",
"snapshot_count": 0, "snapshot_count": 0,
"platform_docs": _metric_copy("platform", "Platform"), "platform_docs": _metric_copy("platform", "Platform"),
"state_docs": _metric_copy("stability_state", "Participant State"), "state_docs": _metric_copy(
"thread_docs": _metric_copy("thread", "Thread"), "stability_state", "Participant State"
"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."
), ),
"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": [], "rows": [],
"docs": [ "docs": [
"Quick Insights needs at least one workspace conversation snapshot.", "Quick Insights needs at least one workspace conversation snapshot.",
@@ -2935,7 +3086,9 @@ class ComposeEngagePreview(LoginRequiredMixin, View):
) )
preview = str(payload.get("preview") or "").strip() preview = str(payload.get("preview") or "").strip()
outbound = _engage_body_only(payload.get("outbound") or "") 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: else:
ai_obj = AI.objects.filter(user=request.user).first() ai_obj = AI.objects.filter(user=request.user).first()
if ai_obj is not None: if ai_obj is not None:
@@ -3062,6 +3215,11 @@ class ComposeSend(LoginRequiredMixin, View):
"panel_id": str(panel_id or ""), "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: if ok:
trigger_payload["composeMessageSent"] = {"panel_id": str(panel_id or "")} trigger_payload["composeMessageSent"] = {"panel_id": str(panel_id or "")}
response["HX-Trigger"] = json.dumps(trigger_payload) response["HX-Trigger"] = json.dumps(trigger_payload)
@@ -3104,12 +3262,48 @@ class ComposeSend(LoginRequiredMixin, View):
) )
base = _context_base(request.user, service, identifier, person) base = _context_base(request.user, service, identifier, person)
ts = async_to_sync(transport.send_message_raw)( from core.util import logs as util_logs
base["service"],
base["identifier"], logger = util_logs.get_logger("compose")
text=text, log_prefix = (
attachments=[], 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: if not ts:
return self._response( return self._response(
request, request,
@@ -3124,15 +3318,34 @@ class ComposeSend(LoginRequiredMixin, View):
user=request.user, user=request.user,
identifier=base["person_identifier"], 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, user=request.user,
session=session, session=session,
sender_uuid="", sender_uuid="",
text=text, text=text,
ts=int(ts) if str(ts).isdigit() else int(time.time() * 1000), ts=msg_ts,
delivered_ts=int(ts) if str(ts).isdigit() else None, delivered_ts=delivered_ts,
custom_author="USER", 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( return self._response(
request, request,

View File

@@ -3,8 +3,8 @@ from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import GroupForm from core.forms import GroupForm
from core.models import Group from core.models import Group
from core.views.osint import OSINTListBase
from core.util import logs from core.util import logs
from core.views.osint import OSINTListBase
log = logs.get_logger(__name__) 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.forms import ManipulationForm
from core.models import Manipulation from core.models import Manipulation
from core.views.osint import OSINTListBase
from core.util import logs from core.util import logs
from core.views.osint import OSINTListBase
log = logs.get_logger(__name__) log = logs.get_logger(__name__)

View File

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

View File

@@ -3,8 +3,8 @@ from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import PersonForm from core.forms import PersonForm
from core.models import Person from core.models import Person
from core.views.osint import OSINTListBase
from core.util import logs from core.util import logs
from core.views.osint import OSINTListBase
log = logs.get_logger(__name__) 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.forms import PersonaForm
from core.models import Persona from core.models import Persona
from core.views.osint import OSINTListBase
from core.util import logs from core.util import logs
from core.views.osint import OSINTListBase
log = logs.get_logger(__name__) log = logs.get_logger(__name__)

View File

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

View File

@@ -19,8 +19,8 @@ from core.models import (
PatternMitigationPlan, PatternMitigationPlan,
PatternMitigationRule, PatternMitigationRule,
Person, Person,
PersonIdentifier,
Persona, Persona,
PersonIdentifier,
QueuedMessage, QueuedMessage,
WorkspaceConversation, WorkspaceConversation,
WorkspaceMetricSnapshot, WorkspaceMetricSnapshot,
@@ -37,7 +37,9 @@ class SystemSettings(SuperUserRequiredMixin, View):
"messages": Message.objects.filter(user=user).count(), "messages": Message.objects.filter(user=user).count(),
"queued_messages": QueuedMessage.objects.filter(user=user).count(), "queued_messages": QueuedMessage.objects.filter(user=user).count(),
"message_events": MessageEvent.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( "workspace_snapshots": WorkspaceMetricSnapshot.objects.filter(
conversation__user=user conversation__user=user
).count(), ).count(),
@@ -57,7 +59,9 @@ class SystemSettings(SuperUserRequiredMixin, View):
"mitigation_auto_settings": PatternMitigationAutoSettings.objects.filter( "mitigation_auto_settings": PatternMitigationAutoSettings.objects.filter(
user=user user=user
).count(), ).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_people": Person.objects.filter(user=user).count(),
"osint_identifiers": PersonIdentifier.objects.filter(user=user).count(), "osint_identifiers": PersonIdentifier.objects.filter(user=user).count(),
"osint_groups": Group.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 += AIResult.objects.filter(user=user).delete()[0]
deleted += AIRequest.objects.filter(user=user).delete()[0] deleted += AIRequest.objects.filter(user=user).delete()[0]
deleted += MemoryItem.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 += MessageEvent.objects.filter(user=user).delete()[0]
deleted += Message.objects.filter(user=user).delete()[0] deleted += Message.objects.filter(user=user).delete()[0]
deleted += QueuedMessage.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.shortcuts import render
from django.urls import reverse from django.urls import reverse
from urllib.parse import urlencode
from django.views import View from django.views import View
from mixins.views import ObjectList, ObjectRead from mixins.views import ObjectList, ObjectRead
from core.clients import transport from core.clients import transport
from core.models import ChatSession, Message, PersonIdentifier 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.compose import _compose_urls, _service_icon_class
from core.views.manage.permissions import SuperUserRequiredMixin from core.views.manage.permissions import SuperUserRequiredMixin
from core.util import logs
import time
log = logs.get_logger("whatsapp_view") log = logs.get_logger("whatsapp_view")
@@ -32,16 +33,13 @@ class WhatsApp(SuperUserRequiredMixin, View):
) )
def delete(self, request, *args, **kwargs): def delete(self, request, *args, **kwargs):
account = ( account = str(request.GET.get("account") or "").strip() or next(
str(request.GET.get("account") or "").strip() (
or next( str(item or "").strip()
( for item in transport.list_accounts("whatsapp")
str(item or "").strip() if str(item or "").strip()
for item in transport.list_accounts("whatsapp") ),
if str(item or "").strip() "",
),
"",
)
) )
if account: if account:
transport.unlink_account("whatsapp", account) transport.unlink_account("whatsapp", account)
@@ -381,9 +379,7 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
def _detail_context(self, kwargs, obj): def _detail_context(self, kwargs, obj):
detail_url_args = { detail_url_args = {
arg: kwargs[arg] arg: kwargs[arg] for arg in self.detail_url_args if arg in kwargs
for arg in self.detail_url_args
if arg in kwargs
} }
return { return {
"object": obj, "object": obj,
@@ -410,7 +406,9 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
sqlite_scanned = int(state.get("history_sqlite_scanned") or 0) sqlite_scanned = int(state.get("history_sqlite_scanned") or 0)
on_demand_requested = bool(state.get("history_on_demand_requested")) 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_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")) history_running = bool(state.get("history_sync_running"))
return [ return [
f"connected={bool(state.get('connected'))}", f"connected={bool(state.get('connected'))}",

View File

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

View File

@@ -271,47 +271,10 @@ services:
# memory: 0.25G # memory: 0.25G
#network_mode: host #network_mode: host
# Optional watcher service to restart the runtime router (UR) when core code changes. # Watchers disabled - use manual restart for ur and scheduling services
# This runs the `docker/watch_and_restart.py` script inside the same image and # uWSGI auto-reload is enabled in uwsgi.ini for core code changes
# will restart the `ur_gia` container when files under `/code/core` change. # To restart ur after code changes: docker-compose restart ur
watch_ur: # To restart scheduling after code changes: docker-compose restart scheduling
image: xf/gia:prod
container_name: watch_ur_gia
build:
context: .
args:
OPERATION: ${OPERATION}
command: sh -c '. /venv/bin/activate && python docker/watch_and_restart.py'
volumes:
- ${REPO_DIR}:/code
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
- type: bind
source: /code/vrun
target: /var/run
environment:
WATCH_PATHS: "/code/core"
TARGET_CONTAINER: "ur_gia"
# Optional watcher service to restart the scheduling process when app code changes.
watch_scheduling:
image: xf/gia:prod
container_name: watch_scheduling_gia
build:
context: .
args:
OPERATION: ${OPERATION}
command: sh -c '. /venv/bin/activate && python docker/watch_and_restart.py'
volumes:
- ${REPO_DIR}:/code
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
- type: bind
source: /code/vrun
target: /var/run
environment:
WATCH_PATHS: "/code/app"
TARGET_CONTAINER: "scheduling_gia"
redis: redis:
image: redis image: redis

View File

@@ -24,12 +24,13 @@ management command), you can "touch" any file under the watched path:
The watcher ignores `__pycache__`, `.pyc` files and `.git` paths. The watcher ignores `__pycache__`, `.pyc` files and `.git` paths.
""" """
import os import os
import subprocess
import sys import sys
import time import time
import subprocess
from pathlib import Path from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
class ChangeHandler(FileSystemEventHandler): class ChangeHandler(FileSystemEventHandler):
@@ -48,7 +49,7 @@ class ChangeHandler(FileSystemEventHandler):
def _check_and_restart(self, path): def _check_and_restart(self, path):
# Ignore pycache and compiled files # Ignore pycache and compiled files
if '__pycache__' in path or '.pyc' in path or '.git' in path: if "__pycache__" in path or ".pyc" in path or ".git" in path:
return return
now = time.time() now = time.time()
@@ -62,13 +63,16 @@ class ChangeHandler(FileSystemEventHandler):
def _restart_ur(self): def _restart_ur(self):
# Determine target container from environment (default `ur_gia`) # Determine target container from environment (default `ur_gia`)
target = os.environ.get('TARGET_CONTAINER', 'ur_gia') target = os.environ.get("TARGET_CONTAINER", "ur_gia")
print(f'[{time.strftime("%H:%M:%S")}] Restarting {target}...', flush=True) print(f'[{time.strftime("%H:%M:%S")}] Restarting {target}...', flush=True)
# Try podman first (preferred in this setup), then docker # Try podman first (preferred in this setup), then docker
cmd = f"podman restart {target} 2>/dev/null || docker restart {target} 2>/dev/null" cmd = f"podman restart {target} 2>/dev/null || docker restart {target} 2>/dev/null"
result = subprocess.run(cmd, shell=True, capture_output=True) result = subprocess.run(cmd, shell=True, capture_output=True)
if result.returncode == 0: if result.returncode == 0:
print(f'[{time.strftime("%H:%M:%S")}] {target} restarted successfully', flush=True) print(
f'[{time.strftime("%H:%M:%S")}] {target} restarted successfully',
flush=True,
)
else: else:
print(f'[{time.strftime("%H:%M:%S")}] {target} restart failed', flush=True) print(f'[{time.strftime("%H:%M:%S")}] {target} restart failed', flush=True)
time.sleep(1) time.sleep(1)
@@ -80,17 +84,20 @@ def main():
# Allow overriding watched paths via environment variable `WATCH_PATHS`. # Allow overriding watched paths via environment variable `WATCH_PATHS`.
# Default is `/code/core,/code/app` but you can set e.g. `WATCH_PATHS=/code/core` # Default is `/code/core,/code/app` but you can set e.g. `WATCH_PATHS=/code/core`
watch_paths_env = os.environ.get('WATCH_PATHS', '/code/core,/code/app') watch_paths_env = os.environ.get("WATCH_PATHS", "/code/core,/code/app")
watch_paths = [p.strip() for p in watch_paths_env.split(',') if p.strip()] watch_paths = [p.strip() for p in watch_paths_env.split(",") if p.strip()]
for path in watch_paths: for path in watch_paths:
if os.path.exists(path): if os.path.exists(path):
observer.schedule(handler, path, recursive=True) observer.schedule(handler, path, recursive=True)
print(f'Watching: {path}', flush=True) print(f"Watching: {path}", flush=True)
else: else:
print(f'Not found (will not watch): {path}', flush=True) print(f"Not found (will not watch): {path}", flush=True)
observer.start() observer.start()
print(f'[{time.strftime("%H:%M:%S")}] File watcher started. Monitoring for changes...', flush=True) print(
f'[{time.strftime("%H:%M:%S")}] File watcher started. Monitoring for changes...',
flush=True,
)
try: try:
while True: while True:
@@ -100,5 +107,5 @@ def main():
observer.join() observer.join()
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

@@ -15,9 +15,9 @@ Touching a file under the watched path will trigger a restart of the target
container; e.g. `touch /code/core/__restart__` will cause the watcher to act. container; e.g. `touch /code/core/__restart__` will cause the watcher to act.
""" """
import os import os
import subprocess
import sys import sys
import time import time
import subprocess
def get_mtime(path): def get_mtime(path):
@@ -25,9 +25,9 @@ def get_mtime(path):
max_mtime = 0 max_mtime = 0
for root, dirs, files in os.walk(path): for root, dirs, files in os.walk(path):
# Skip pycache and hidden dirs # Skip pycache and hidden dirs
dirs[:] = [d for d in dirs if not d.startswith('.') and d != '__pycache__'] dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"]
for file in files: for file in files:
if file.endswith(('.pyc', '.pyo')): if file.endswith((".pyc", ".pyo")):
continue continue
try: try:
mtime = os.path.getmtime(os.path.join(root, file)) mtime = os.path.getmtime(os.path.join(root, file))
@@ -39,9 +39,9 @@ def get_mtime(path):
def restart_ur(): def restart_ur():
"""Restart target container (defaults to `ur_gia`).""" """Restart target container (defaults to `ur_gia`)."""
target = os.environ.get('TARGET_CONTAINER', 'ur_gia') target = os.environ.get("TARGET_CONTAINER", "ur_gia")
print(f'[{time.strftime("%H:%M:%S")}] Restarting {target}...', flush=True) print(f'[{time.strftime("%H:%M:%S")}] Restarting {target}...', flush=True)
cmd = f'podman restart {target} 2>/dev/null || docker restart {target} 2>/dev/null' cmd = f"podman restart {target} 2>/dev/null || docker restart {target} 2>/dev/null"
result = subprocess.run(cmd, shell=True, capture_output=True) result = subprocess.run(cmd, shell=True, capture_output=True)
if result.returncode == 0: if result.returncode == 0:
print(f'[{time.strftime("%H:%M:%S")}] {target} restarted', flush=True) print(f'[{time.strftime("%H:%M:%S")}] {target} restarted', flush=True)
@@ -53,16 +53,16 @@ def main():
# In the container the repository is mounted at /code # In the container the repository is mounted at /code
# Allow overriding watched paths via environment variable `WATCH_PATHS`. # Allow overriding watched paths via environment variable `WATCH_PATHS`.
# Default is `/code/core,/code/app`. # Default is `/code/core,/code/app`.
paths_env = os.environ.get('WATCH_PATHS', '/code/core,/code/app') paths_env = os.environ.get("WATCH_PATHS", "/code/core,/code/app")
paths = [p.strip() for p in paths_env.split(',') if p.strip()] paths = [p.strip() for p in paths_env.split(",") if p.strip()]
last_mtimes = {} last_mtimes = {}
for path in paths: for path in paths:
if os.path.exists(path): if os.path.exists(path):
print(f'Watching: {path}', flush=True) print(f"Watching: {path}", flush=True)
last_mtimes[path] = get_mtime(path) last_mtimes[path] = get_mtime(path)
else: else:
print(f'Not found: {path}', flush=True) print(f"Not found: {path}", flush=True)
print(f'[{time.strftime("%H:%M:%S")}] Watcher started', flush=True) print(f'[{time.strftime("%H:%M:%S")}] Watcher started', flush=True)
restart_debounce = 0 restart_debounce = 0
@@ -77,15 +77,17 @@ def main():
continue continue
current_mtime = get_mtime(path) current_mtime = get_mtime(path)
if current_mtime > last_mtimes.get(path, 0): if current_mtime > last_mtimes.get(path, 0):
print(f'[{time.strftime("%H:%M:%S")}] Changes in {path}', flush=True) print(
f'[{time.strftime("%H:%M:%S")}] Changes in {path}', flush=True
)
last_mtimes[path] = current_mtime last_mtimes[path] = current_mtime
if restart_debounce <= 0: if restart_debounce <= 0:
restart_ur() restart_ur()
restart_debounce = 5 # Don't restart more than every 5s restart_debounce = 5 # Don't restart more than every 5s
except KeyboardInterrupt: except KeyboardInterrupt:
print('Watcher stopped', flush=True) print("Watcher stopped", flush=True)
sys.exit(0) sys.exit(0)
if __name__ == '__main__': if __name__ == "__main__":
main() main()