Fully implement WhatsApp, Signal and XMPP multiplexing

This commit is contained in:
2026-02-16 19:19:32 +00:00
parent 3f82c27ab9
commit 658ab10647
9 changed files with 659 additions and 111 deletions

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
import time
from urllib.parse import quote_plus, urlparse from urllib.parse import quote_plus, urlparse
import aiohttp import aiohttp
@@ -472,6 +473,14 @@ class HandleMessage(Command):
outgoing=is_from_bot, outgoing=is_from_bot,
) )
stored_messages.add(message_key) stored_messages.add(message_key)
# Notify unified router to ensure service context is preserved
await self.ur.message_received(
self.service,
identifier=identifier,
text=message_text,
ts=ts,
payload=msg,
)
# TODO: Permission checks # TODO: Permission checks
manips = await sync_to_async(list)(Manipulation.objects.filter(enabled=True)) manips = await sync_to_async(list)(Manipulation.objects.filter(enabled=True))
@@ -595,6 +604,7 @@ class HandleMessage(Command):
class SignalClient(ClientBase): class SignalClient(ClientBase):
def __init__(self, ur, *args, **kwargs): def __init__(self, ur, *args, **kwargs):
super().__init__(ur, *args, **kwargs) super().__init__(ur, *args, **kwargs)
self._stopping = False
signal_number = str(getattr(settings, "SIGNAL_NUMBER", "")).strip() signal_number = str(getattr(settings, "SIGNAL_NUMBER", "")).strip()
self.client = NewSignalBot( self.client = NewSignalBot(
ur, ur,
@@ -606,9 +616,121 @@ class SignalClient(ClientBase):
) )
self.client.register(HandleMessage(self.ur, self.service)) self.client.register(HandleMessage(self.ur, self.service))
self._command_task = None
async def _drain_runtime_commands(self):
"""Process queued runtime commands (e.g., web UI sends via composite router)."""
from core.clients import transport
# Process a small burst each loop to keep sends responsive.
for _ in range(5):
command = transport.pop_runtime_command(self.service)
if not command:
return
await self._execute_runtime_command(command)
async def _execute_runtime_command(self, command):
"""Execute a single runtime command like send_message_raw."""
from core.clients import transport
command_id = str((command or {}).get("id") or "").strip()
action = str((command or {}).get("action") or "").strip()
payload = dict((command or {}).get("payload") or {})
if not command_id:
return
if action == "send_message_raw":
recipient = str(payload.get("recipient") or "").strip()
text = payload.get("text")
attachments = payload.get("attachments") or []
try:
result = await signalapi.send_message_raw(
recipient_uuid=recipient,
text=text,
attachments=attachments,
)
if result is False or result is None:
raise RuntimeError("signal_send_failed")
transport.set_runtime_command_result(
self.service,
command_id,
{
"ok": True,
"timestamp": int(result)
if isinstance(result, int)
else int(time.time() * 1000),
},
)
except Exception as exc:
self.log.error(f"send_message_raw failed: {exc}", exc_info=True)
transport.set_runtime_command_result(
self.service,
command_id,
{
"ok": False,
"error": str(exc),
},
)
return
if action == "notify_xmpp_sent":
person_identifier_id = str(payload.get("person_identifier_id") or "").strip()
text = str(payload.get("text") or "")
if not person_identifier_id:
transport.set_runtime_command_result(
self.service,
command_id,
{"ok": False, "error": "missing_person_identifier_id"},
)
return
try:
identifier = await sync_to_async(
lambda: PersonIdentifier.objects.filter(id=person_identifier_id).select_related("user", "person").first()
)()
if identifier is None:
transport.set_runtime_command_result(
self.service,
command_id,
{"ok": False, "error": "person_identifier_not_found"},
)
return
await self.ur.xmpp.client.send_from_external(
identifier.user,
identifier,
text,
True,
attachments=[],
)
transport.set_runtime_command_result(
self.service,
command_id,
{"ok": True, "timestamp": int(time.time() * 1000)},
)
except Exception as exc:
transport.set_runtime_command_result(
self.service,
command_id,
{"ok": False, "error": str(exc)},
)
return
transport.set_runtime_command_result(
self.service,
command_id,
{"ok": False, "error": f"unsupported_action:{action or '-'}"},
)
async def _command_loop(self):
"""Background task to periodically drain queued commands."""
while not self._stopping:
try:
await self._drain_runtime_commands()
except Exception as exc:
self.log.warning(f"Command loop error: {exc}")
await asyncio.sleep(1)
def start(self): def start(self):
self.log.info("Signal client starting...") self.log.info("Signal client starting...")
self.client._event_loop = self.loop self.client._event_loop = self.loop
# Start background command processing loop
if not self._command_task or self._command_task.done():
self._command_task = self.loop.create_task(self._command_loop())
self.client.start() self.client.start()

View File

@@ -44,6 +44,10 @@ def _runtime_command_cancel_key(service: str, command_id: str) -> str:
return f"gia:service:command-cancel:{_service_key(service)}:{command_id}" return f"gia:service:command-cancel:{_service_key(service)}:{command_id}"
def _runtime_command_meta_key(service: str, command_id: str) -> str:
return f"gia:service:command-meta:{_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"
@@ -110,6 +114,14 @@ def enqueue_runtime_command(
if len(queued) > 200: if len(queued) > 200:
queued = queued[-200:] queued = queued[-200:]
cache.set(key, queued, timeout=_RUNTIME_COMMANDS_TTL) cache.set(key, queued, timeout=_RUNTIME_COMMANDS_TTL)
cache.set(
_runtime_command_meta_key(service_key, command_id),
{
"created_at": int(command.get("created_at") or int(time.time())),
"action": str(command.get("action") or ""),
},
timeout=_RUNTIME_COMMANDS_TTL,
)
return command_id return command_id
@@ -132,6 +144,7 @@ def set_runtime_command_result(
payload = dict(result or {}) payload = dict(result or {})
payload.setdefault("completed_at", int(time.time())) payload.setdefault("completed_at", int(time.time()))
cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL) cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL)
cache.delete(_runtime_command_meta_key(service_key, command_id))
def cancel_runtime_command(service: str, command_id: str): def cancel_runtime_command(service: str, command_id: str):
@@ -142,9 +155,24 @@ def cancel_runtime_command(service: str, command_id: str):
payload = {"ok": False, "error": "cancelled", "completed_at": int(time.time())} payload = {"ok": False, "error": "cancelled", "completed_at": int(time.time())}
cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL) cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL)
cache.set(cancel_key, True, timeout=60) cache.set(cancel_key, True, timeout=60)
cache.delete(_runtime_command_meta_key(service_key, command_id))
return True return True
def runtime_command_age_seconds(service: str, command_id: str) -> float | None:
service_key = _service_key(service)
meta = cache.get(_runtime_command_meta_key(service_key, command_id))
if not isinstance(meta, dict):
return None
try:
created_at = int(meta.get("created_at") or 0)
except Exception:
created_at = 0
if created_at <= 0:
return None
return max(0.0, time.time() - created_at)
def cancel_runtime_commands_for_recipient(service: str, recipient: str) -> list[str]: 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.""" """Cancel any queued runtime commands for the given recipient and return their ids."""
service_key = _service_key(service) service_key = _service_key(service)

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import inspect
import logging import logging
import os import os
import re import re
@@ -42,6 +43,7 @@ class WhatsAppClient(ClientBase):
self._qr_handler_registered = False self._qr_handler_registered = False
self._qr_handler_supported = False self._qr_handler_supported = False
self._event_hook_callable = False self._event_hook_callable = False
self._last_send_error = ""
self.enabled = bool( self.enabled = bool(
str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower() str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
@@ -658,6 +660,17 @@ class WhatsAppClient(ClientBase):
return await value return await value
return value return value
async def _call_client_method(self, method, *args, timeout: float | None = None):
if method is None:
return None
if inspect.iscoroutinefunction(method):
coro = method(*args)
else:
coro = asyncio.to_thread(method, *args)
if timeout and timeout > 0:
return await asyncio.wait_for(coro, timeout=timeout)
return await coro
async def _drain_runtime_commands(self): async def _drain_runtime_commands(self):
# Process a small burst each loop to keep sends responsive but avoid starvation. # Process a small burst each loop to keep sends responsive but avoid starvation.
for _ in range(5): for _ in range(5):
@@ -672,18 +685,27 @@ class WhatsAppClient(ClientBase):
payload = dict((command or {}).get("payload") or {}) payload = dict((command or {}).get("payload") or {})
if not command_id: if not command_id:
return return
self.log.info(
"whatsapp runtime command start: id=%s action=%s",
command_id,
action,
)
if action == "send_message_raw": if action == "send_message_raw":
recipient = str(payload.get("recipient") or "").strip() recipient = str(payload.get("recipient") or "").strip()
text = payload.get("text") text = payload.get("text")
attachments = payload.get("attachments") or [] attachments = payload.get("attachments") or []
send_timeout_s = 18.0
try: try:
# Include command_id so send_message_raw can observe cancel requests # Include command_id so send_message_raw can observe cancel requests
result = await self.send_message_raw( result = await asyncio.wait_for(
recipient=recipient, self.send_message_raw(
text=text, recipient=recipient,
attachments=attachments, text=text,
command_id=command_id, attachments=attachments,
command_id=command_id,
),
timeout=send_timeout_s,
) )
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(
@@ -696,15 +718,45 @@ class WhatsAppClient(ClientBase):
else int(time.time() * 1000), else int(time.time() * 1000),
}, },
) )
self.log.info(
"whatsapp runtime command ok: id=%s action=%s",
command_id,
action,
)
return return
transport.set_runtime_command_result( transport.set_runtime_command_result(
self.service, self.service,
command_id, command_id,
{ {
"ok": False, "ok": False,
"error": "runtime_send_failed", "error": str(
getattr(self, "_last_send_error", "")
or "runtime_send_failed"
),
}, },
) )
self.log.warning(
"whatsapp runtime command failed: id=%s action=%s error=%s",
command_id,
action,
str(getattr(self, "_last_send_error", "") or "runtime_send_failed"),
)
return
except asyncio.TimeoutError:
transport.set_runtime_command_result(
self.service,
command_id,
{
"ok": False,
"error": f"runtime_send_timeout:{int(send_timeout_s)}s",
},
)
self.log.warning(
"whatsapp runtime command timeout: id=%s action=%s timeout=%ss",
command_id,
action,
int(send_timeout_s),
)
return return
except Exception as exc: except Exception as exc:
transport.set_runtime_command_result( transport.set_runtime_command_result(
@@ -715,6 +767,12 @@ class WhatsAppClient(ClientBase):
"error": str(exc), "error": str(exc),
}, },
) )
self.log.warning(
"whatsapp runtime command exception: id=%s action=%s error=%s",
command_id,
action,
exc,
)
return return
if action == "force_history_sync": if action == "force_history_sync":
@@ -741,6 +799,48 @@ class WhatsAppClient(ClientBase):
) )
return return
if action == "notify_xmpp_sent":
person_identifier_id = str(payload.get("person_identifier_id") or "").strip()
text = str(payload.get("text") or "")
if not person_identifier_id:
transport.set_runtime_command_result(
self.service,
command_id,
{"ok": False, "error": "missing_person_identifier_id"},
)
return
try:
identifier = await sync_to_async(
lambda: PersonIdentifier.objects.filter(id=person_identifier_id).select_related("user", "person").first()
)()
if identifier is None:
transport.set_runtime_command_result(
self.service,
command_id,
{"ok": False, "error": "person_identifier_not_found"},
)
return
await self.ur.xmpp.client.send_from_external(
identifier.user,
identifier,
text,
True,
attachments=[],
)
transport.set_runtime_command_result(
self.service,
command_id,
{"ok": True, "timestamp": int(time.time() * 1000)},
)
return
except Exception as exc:
transport.set_runtime_command_result(
self.service,
command_id,
{"ok": False, "error": str(exc)},
)
return
transport.set_runtime_command_result( transport.set_runtime_command_result(
self.service, self.service,
command_id, command_id,
@@ -2338,27 +2438,43 @@ class WhatsAppClient(ClientBase):
async def send_message_raw( async def send_message_raw(
self, recipient, text=None, attachments=None, command_id: str | None = None self, recipient, text=None, attachments=None, command_id: str | None = None
): ):
self._last_send_error = ""
if not self._client: if not self._client:
return False self._last_send_error = "client_missing"
if self._build_jid is None:
return False return False
jid_str = self._to_jid(recipient) jid_str = self._to_jid(recipient)
if not jid_str: if not jid_str:
self._last_send_error = "recipient_invalid"
return False return False
# Convert string JID to actual JID object that neonize expects # Prefer direct JID string for sends to avoid Neonize usync/device-list
# lookups that can stall on some runtime sessions.
jid = jid_str
jid_obj = None
try: try:
jid = self._build_jid(jid_str) if self._build_jid is not None:
# Verify it's a proper JID object with SerializeToString method maybe_jid = None
if not hasattr(jid, "SerializeToString"): if "@" in jid_str:
self.log.error( local_part, server_part = jid_str.split("@", 1)
"whatsapp build_jid returned non-JID object: type=%s repr=%s", try:
type(jid).__name__, maybe_jid = self._build_jid(local_part, server_part)
repr(jid)[:100], except TypeError:
) maybe_jid = self._build_jid(jid_str)
return False else:
maybe_jid = self._build_jid(jid_str)
if hasattr(maybe_jid, "SerializeToString"):
jid_obj = maybe_jid
else:
self.log.warning(
"whatsapp build_jid returned non-JID object, falling back to string: type=%s repr=%s",
type(maybe_jid).__name__,
repr(maybe_jid)[:100],
)
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(
return False "whatsapp failed to build JID from %s, falling back to string: %s",
jid_str,
exc,
)
if not self._connected and hasattr(self._client, "connect"): if not self._connected and hasattr(self._client, "connect"):
try: try:
await self._maybe_await(self._client.connect()) await self._maybe_await(self._client.connect())
@@ -2370,6 +2486,7 @@ class WhatsAppClient(ClientBase):
last_error="", last_error="",
) )
except Exception as exc: except Exception as exc:
self._last_send_error = f"reconnect_failed:{exc}"
self._publish_state( self._publish_state(
connected=False, connected=False,
last_event="send_reconnect_failed", last_event="send_reconnect_failed",
@@ -2388,24 +2505,25 @@ class WhatsAppClient(ClientBase):
).lower() ).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"
attachment_target = jid_obj if jid_obj is not None else jid
try: try:
if mime.startswith("image/") and hasattr(self._client, "send_image"): if mime.startswith("image/") and hasattr(self._client, "send_image"):
response = await self._maybe_await( response = await self._maybe_await(
self._client.send_image(jid, data, caption="") self._client.send_image(attachment_target, data, caption="")
) )
elif mime.startswith("video/") and hasattr(self._client, "send_video"): elif mime.startswith("video/") and hasattr(self._client, "send_video"):
response = await self._maybe_await( response = await self._maybe_await(
self._client.send_video(jid, data, caption="") self._client.send_video(attachment_target, 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( response = await self._maybe_await(
self._client.send_audio(jid, data) self._client.send_audio(attachment_target, 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(
jid, attachment_target,
data, data,
filename=filename, filename=filename,
mimetype=mime, mimetype=mime,
@@ -2435,22 +2553,27 @@ class WhatsAppClient(ClientBase):
except Exception: except Exception:
cancel_key = None cancel_key = None
for attempt in range(5): # Increased from 3 to 5 attempts for attempt in range(2):
# Check for a cancellation marker set by transport.cancel_runtime_command # Check for a cancellation marker set by transport.cancel_runtime_command
try: try:
if cancel_key and cache.get(cancel_key): if cancel_key and cache.get(cancel_key):
self.log.info("whatsapp send cancelled via cancel marker") self.log.info("whatsapp send cancelled via cancel marker")
self._last_send_error = "cancelled"
return False return False
except Exception: except Exception:
pass pass
try: try:
send_target = jid_obj if jid_obj is not None else jid
# Log what we're about to send for debugging # Log what we're about to send for debugging
if getattr(settings, "WHATSAPP_DEBUG", False): if getattr(settings, "WHATSAPP_DEBUG", False):
self.log.debug( self.log.debug(
f"send_message attempt {attempt+1}: jid={jid} text_type={type(text).__name__} text_len={len(text)}" f"send_message attempt {attempt+1}: target_type={type(send_target).__name__} text_type={type(text).__name__} text_len={len(text)}"
) )
response = await self._maybe_await( response = await self._call_client_method(
self._client.send_message(jid, text) getattr(self._client, "send_message", None),
send_target,
text,
timeout=9.0,
) )
sent_any = True sent_any = True
last_error = None last_error = None
@@ -2462,9 +2585,31 @@ class WhatsAppClient(ClientBase):
) )
last_error = exc last_error = exc
error_text = str(last_error or "").lower() error_text = (
is_transient = "usync query" in error_text or "timed out" in error_text f"{type(last_error).__name__}:{repr(last_error)}"
if is_transient and attempt < 4: # Updated to match new attempt range if last_error is not None
else ""
).lower()
is_transient = (
"usync" in error_text
or "timed out" in error_text
or "timeout" in error_text
or "device list" in error_text
or "serializetostring" in error_text
or not error_text.strip()
)
if is_transient and attempt < 1:
# If runtime rejected string target, try to build protobuf JID for retry.
if jid_obj is None and self._build_jid is not None and "@" in jid_str:
local_part, server_part = jid_str.split("@", 1)
try:
maybe_retry_jid = self._build_jid(local_part, server_part)
except TypeError:
maybe_retry_jid = self._build_jid(jid_str)
except Exception:
maybe_retry_jid = None
if hasattr(maybe_retry_jid, "SerializeToString"):
jid_obj = maybe_retry_jid
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())
@@ -2482,7 +2627,7 @@ class WhatsAppClient(ClientBase):
) )
# Sleep but wake earlier if cancelled: poll small intervals # Sleep but wake earlier if cancelled: poll small intervals
# Increase backoff time for device list queries # Increase backoff time for device list queries
total_sleep = 1.5 * (attempt + 1) total_sleep = 0.8 * (attempt + 1)
slept = 0.0 slept = 0.0
while slept < total_sleep: while slept < total_sleep:
try: try:
@@ -2490,6 +2635,7 @@ class WhatsAppClient(ClientBase):
self.log.info( self.log.info(
"whatsapp send cancelled during retry backoff" "whatsapp send cancelled during retry backoff"
) )
self._last_send_error = "cancelled"
return False return False
except Exception: except Exception:
pass pass
@@ -2499,6 +2645,9 @@ class WhatsAppClient(ClientBase):
break break
if last_error is not None and not sent_any: if last_error is not None and not sent_any:
self.log.warning("whatsapp text send failed: %s", last_error) self.log.warning("whatsapp text send failed: %s", last_error)
self._last_send_error = (
f"text_send_failed:{type(last_error).__name__}:{repr(last_error)}"
)
return False return False
sent_ts = max( sent_ts = max(
sent_ts, sent_ts,
@@ -2506,7 +2655,9 @@ class WhatsAppClient(ClientBase):
) )
if not sent_any: if not sent_any:
self._last_send_error = "no_payload_sent"
return False return False
self._last_send_error = ""
return sent_ts or int(time.time() * 1000) return sent_ts or int(time.time() * 1000)
async def start_typing(self, identifier): async def start_typing(self, identifier):

View File

@@ -6,9 +6,13 @@ from urllib.parse import parse_qs
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
from django.core import signing from django.core import signing
from core.models import ChatSession, Message, PersonIdentifier, WorkspaceConversation from core.models import Message, Person, 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 COMPOSE_WS_TOKEN_SALT, _serialize_messages_with_artifacts from core.views.compose import (
COMPOSE_WS_TOKEN_SALT,
ComposeHistorySync,
_serialize_messages_with_artifacts,
)
def _safe_int(value, default=0): def _safe_int(value, default=0):
@@ -19,77 +23,108 @@ def _safe_int(value, default=0):
def _load_since(user_id, service, identifier, person_id, after_ts, limit): def _load_since(user_id, service, identifier, person_id, after_ts, limit):
person = None
person_identifier = None person_identifier = None
if person_id: resolved_person_id = _safe_int(person_id)
if resolved_person_id > 0:
person = Person.objects.filter(id=resolved_person_id, user_id=user_id).first()
if person is not None:
person_identifier = ( person_identifier = (
PersonIdentifier.objects.filter( PersonIdentifier.objects.filter(
user_id=user_id, user_id=user_id,
person_id=person_id, person_id=person.id,
service=service, service=service,
).first() ).first()
or PersonIdentifier.objects.filter( or PersonIdentifier.objects.filter(
user_id=user_id, user_id=user_id,
person_id=person_id, person_id=person.id,
).first() ).first()
) )
if person_identifier is None and identifier: elif identifier:
person_identifier = PersonIdentifier.objects.filter( person_identifier = PersonIdentifier.objects.filter(
user_id=user_id, user_id=user_id,
service=service, service=service,
identifier=identifier, identifier=identifier,
).first() ).first()
if person_identifier is None:
return {"messages": [], "last_ts": after_ts, "person_id": 0}
session = ChatSession.objects.filter( session_ids = ComposeHistorySync._session_ids_for_scope(
user_id=user_id, user=user_id,
identifier=person_identifier, person=person,
).first() service=service,
if session is None: person_identifier=person_identifier,
explicit_identifier=identifier,
)
if not session_ids:
return { return {
"messages": [], "messages": [],
"last_ts": after_ts, "last_ts": int(after_ts or 0),
"person_id": int(person_identifier.person_id), "person_id": int(person.id) if person is not None else 0,
} }
qs = Message.objects.filter(user_id=user_id, session=session).order_by("ts") base_queryset = Message.objects.filter(
user_id=user_id,
session_id__in=session_ids,
)
qs = base_queryset.order_by("ts")
seed_previous = None seed_previous = None
if after_ts > 0: if after_ts > 0:
seed_previous = ( seed_previous = (
Message.objects.filter( base_queryset.filter(ts__lte=after_ts).order_by("-ts").first()
user_id=user_id,
session=session,
ts__lte=after_ts,
)
.order_by("-ts")
.first()
) )
qs = qs.filter(ts__gt=after_ts) # Use a small rolling window to capture late/out-of-order timestamps.
# Frontend dedupes by message id, so repeated rows are ignored.
window_start = max(0, int(after_ts) - 5 * 60 * 1000)
qs = qs.filter(ts__gte=window_start)
rows = list(qs[: max(10, min(limit, 200))]) rows_desc = list(
qs.select_related(
"session",
"session__identifier",
"session__identifier__person",
)
.order_by("-ts")[: max(10, min(limit, 200))]
)
rows_desc.reverse()
rows = rows_desc
newest = ( newest = (
Message.objects.filter(user_id=user_id, session=session) Message.objects.filter(
user_id=user_id,
session_id__in=session_ids,
)
.order_by("-ts") .order_by("-ts")
.values_list("ts", flat=True) .values_list("ts", flat=True)
.first() .first()
) )
conversation = ( effective_person_id = (
WorkspaceConversation.objects.filter( int(person.id)
user_id=user_id, if person is not None
participants__id=person_identifier.person_id, else (int(person_identifier.person_id) if person_identifier is not None else 0)
)
.order_by("-last_event_ts", "-created_at")
.first()
) )
counterpart_identifiers = {
str(value or "").strip() conversation = None
for value in PersonIdentifier.objects.filter( counterpart_identifiers = set()
user_id=user_id, if effective_person_id > 0:
person_id=person_identifier.person_id, conversation = (
).values_list("identifier", flat=True) WorkspaceConversation.objects.filter(
if str(value or "").strip() user_id=user_id,
} participants__id=effective_person_id,
)
.order_by("-last_event_ts", "-created_at")
.first()
)
counterpart_identifiers = {
str(value or "").strip()
for value in PersonIdentifier.objects.filter(
user_id=user_id,
person_id=effective_person_id,
).values_list("identifier", flat=True)
if str(value or "").strip()
}
return { return {
"messages": _serialize_messages_with_artifacts( "messages": _serialize_messages_with_artifacts(
rows, rows,
@@ -98,7 +133,7 @@ def _load_since(user_id, service, identifier, person_id, after_ts, limit):
seed_previous=seed_previous, seed_previous=seed_previous,
), ),
"last_ts": int(newest or after_ts or 0), "last_ts": int(newest or after_ts or 0),
"person_id": int(person_identifier.person_id), "person_id": int(effective_person_id),
} }
@@ -133,6 +168,7 @@ async def compose_ws_application(scope, receive, send):
last_ts = 0 last_ts = 0
limit = 100 limit = 100
last_typing_key = "" last_typing_key = ""
sent_message_ids = set()
while True: while True:
event = None event = None
@@ -159,7 +195,15 @@ async def compose_ws_application(scope, receive, send):
after_ts=last_ts, after_ts=last_ts,
limit=limit, limit=limit,
) )
messages = payload.get("messages") or [] raw_messages = payload.get("messages") or []
messages = []
for msg in raw_messages:
message_id = str((msg or {}).get("id") or "").strip()
if message_id and message_id in sent_message_ids:
continue
if message_id:
sent_message_ids.add(message_id)
messages.append(msg)
latest = _safe_int(payload.get("last_ts"), last_ts) latest = _safe_int(payload.get("last_ts"), last_ts)
if resolved_person_id <= 0: if resolved_person_id <= 0:
resolved_person_id = _safe_int(payload.get("person_id"), 0) resolved_person_id = _safe_int(payload.get("person_id"), 0)

View File

@@ -242,7 +242,7 @@
data-engage-preview-url="{{ compose_engage_preview_url }}" data-engage-preview-url="{{ compose_engage_preview_url }}"
data-engage-send-url="{{ compose_engage_send_url }}"> data-engage-send-url="{{ compose_engage_send_url }}">
{% for msg in serialized_messages %} {% for msg in serialized_messages %}
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}"> <div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}">
{% if msg.gap_fragments %} {% if msg.gap_fragments %}
{% with gap=msg.gap_fragments.0 %} {% with gap=msg.gap_fragments.0 %}
<p <p
@@ -1263,6 +1263,7 @@
lightboxKeyHandler: null, lightboxKeyHandler: null,
lightboxImages: [], lightboxImages: [],
lightboxIndex: -1, lightboxIndex: -1,
seenMessageIds: new Set(),
}; };
window.giaComposePanels[panelId] = panelState; window.giaComposePanels[panelId] = panelState;
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger")); const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
@@ -1389,6 +1390,11 @@
}; };
let lastTs = toInt(thread.dataset.lastTs); let lastTs = toInt(thread.dataset.lastTs);
panelState.seenMessageIds = new Set(
Array.from(thread.querySelectorAll(".compose-row"))
.map(function (row) { return String(row.dataset.messageId || "").trim(); })
.filter(Boolean)
);
let glanceState = { let glanceState = {
gap: null, gap: null,
metrics: [], metrics: [],
@@ -1757,13 +1763,42 @@
row.appendChild(chip); row.appendChild(chip);
}; };
const insertRowByTs = function (row) {
const newTs = toInt(row && row.dataset ? row.dataset.ts : 0);
const rows = Array.from(thread.querySelectorAll(".compose-row"));
if (!rows.length) {
thread.appendChild(row);
return;
}
for (let index = rows.length - 1; index >= 0; index -= 1) {
const existing = rows[index];
const existingTs = toInt(existing.dataset ? existing.dataset.ts : 0);
if (existingTs <= newTs) {
if (existing.nextSibling) {
thread.insertBefore(row, existing.nextSibling);
} else {
thread.appendChild(row);
}
return;
}
}
thread.insertBefore(row, rows[0]);
};
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 messageId = String(msg && msg.id ? msg.id : "").trim();
if (messageId && panelState.seenMessageIds.has(messageId)) {
return;
}
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");
row.dataset.ts = String(msg.ts || 0); row.dataset.ts = String(msg.ts || 0);
row.dataset.minute = minuteBucketFromTs(msg.ts || 0); row.dataset.minute = minuteBucketFromTs(msg.ts || 0);
if (messageId) {
row.dataset.messageId = messageId;
panelState.seenMessageIds.add(messageId);
}
appendLatencyChip(row, msg); appendLatencyChip(row, msg);
const bubble = document.createElement("article"); const bubble = document.createElement("article");
@@ -1771,7 +1806,6 @@
// Add source badge for client-side rendered messages // Add source badge for client-side rendered messages
if (msg.source_label) { if (msg.source_label) {
console.log("[appendBubble] rendering source badge:", msg.source_label);
const badgeWrap = document.createElement("div"); const badgeWrap = document.createElement("div");
badgeWrap.className = "compose-source-badge-wrap"; badgeWrap.className = "compose-source-badge-wrap";
const badge = document.createElement("span"); const badge = document.createElement("span");
@@ -1868,7 +1902,8 @@
if (emptyWrap) { if (emptyWrap) {
emptyWrap.remove(); emptyWrap.remove();
} }
thread.appendChild(row); row.appendChild(bubble);
insertRowByTs(row);
wireImageFallbacks(row); wireImageFallbacks(row);
updateGlanceFromMessage(msg); updateGlanceFromMessage(msg);
}; };
@@ -2033,18 +2068,15 @@
} }
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);
@@ -2283,6 +2315,7 @@
thread.innerHTML = '<p class="compose-empty">Loading messages...</p>'; thread.innerHTML = '<p class="compose-empty">Loading messages...</p>';
lastTs = 0; lastTs = 0;
thread.dataset.lastTs = "0"; thread.dataset.lastTs = "0";
panelState.seenMessageIds = new Set();
glanceState = { gap: null, metrics: [] }; glanceState = { gap: null, metrics: [] };
renderGlanceItems([]); renderGlanceItems([]);
poll(true); poll(true);
@@ -3083,10 +3116,15 @@
// HTMX will dispatch a `composeSendCommandId` event with detail {command_id: "..."}. // HTMX will dispatch a `composeSendCommandId` event with detail {command_id: "..."}.
panelState.pendingCommandId = null; panelState.pendingCommandId = null;
panelState.pendingCommandPoll = null; panelState.pendingCommandPoll = null;
panelState.pendingCommandAttempts = 0;
panelState.pendingCommandStartedAt = 0;
panelState.pendingCommandInFlight = false;
const startPendingCommandPolling = function (commandId) { const startPendingCommandPolling = function (commandId) {
if (!commandId) return; if (!commandId) return;
panelState.pendingCommandId = commandId; panelState.pendingCommandId = commandId;
panelState.pendingCommandAttempts = 0;
panelState.pendingCommandStartedAt = Date.now();
// Show persistent cancel UI // Show persistent cancel UI
showPersistentCancelButton(commandId); showPersistentCancelButton(commandId);
// Poll for result every 1500ms // Poll for result every 1500ms
@@ -3094,12 +3132,29 @@
clearInterval(panelState.pendingCommandPoll); clearInterval(panelState.pendingCommandPoll);
} }
panelState.pendingCommandPoll = setInterval(async function () { panelState.pendingCommandPoll = setInterval(async function () {
if (panelState.pendingCommandInFlight) {
return;
}
panelState.pendingCommandAttempts += 1;
const elapsedMs = Date.now() - (panelState.pendingCommandStartedAt || Date.now());
if (panelState.pendingCommandAttempts > 14 || elapsedMs > 45000) {
stopPendingCommandPolling();
hidePersistentCancelButton();
setStatus('Send timed out waiting for runtime result. Please retry.', 'warning');
return;
}
try { try {
panelState.pendingCommandInFlight = true;
const url = new URL('{% url "compose_command_result" %}', window.location.origin); const url = new URL('{% url "compose_command_result" %}', window.location.origin);
url.searchParams.set('service', thread.dataset.service || ''); url.searchParams.set('service', thread.dataset.service || '');
url.searchParams.set('command_id', commandId); url.searchParams.set('command_id', commandId);
const resp = await fetch(url.toString(), { credentials: 'same-origin' }); url.searchParams.set('format', 'json');
const resp = await fetch(url.toString(), {
credentials: 'same-origin',
headers: { 'HX-Request': 'true' },
});
if (!resp.ok) return; if (!resp.ok) return;
if (resp.status === 204) return;
const payload = await resp.json(); const payload = await resp.json();
if (payload && payload.pending === false) { if (payload && payload.pending === false) {
// Stop polling // Stop polling
@@ -3123,8 +3178,10 @@
} }
} catch (e) { } catch (e) {
// ignore transient network errors // ignore transient network errors
} finally {
panelState.pendingCommandInFlight = false;
} }
}, 1500); }, 3500);
}; };
const stopPendingCommandPolling = function () { const stopPendingCommandPolling = function () {
@@ -3133,6 +3190,9 @@
panelState.pendingCommandPoll = null; panelState.pendingCommandPoll = null;
} }
panelState.pendingCommandId = null; panelState.pendingCommandId = null;
panelState.pendingCommandAttempts = 0;
panelState.pendingCommandStartedAt = 0;
panelState.pendingCommandInFlight = false;
}; };
const persistentCancelContainerId = panelId + '-persistent-cancel'; const persistentCancelContainerId = panelId + '-persistent-cancel';

View File

@@ -354,29 +354,29 @@ def _serialize_message(msg: Message) -> dict:
source_service = svc source_service = svc
except Exception: except Exception:
pass pass
sender_uuid_value = str(getattr(msg, "sender_uuid", "") or "").strip()
if sender_uuid_value.lower() == "xmpp":
source_service = "xmpp"
from core.util import logs as util_logs # Outgoing messages created by the web compose UI should be labeled Web Chat.
# Outgoing messages originating from platform runtimes (Signal sync, etc.)
logger = util_logs.get_logger("compose") # should keep their service label.
logger.info( service_labels = {
f"[serialize_message] id={msg.id} author={author} is_outgoing={is_outgoing} source_service={source_service}" "xmpp": "XMPP",
) "whatsapp": "WhatsApp",
"signal": "Signal",
# For outgoing messages sent from web UI, label as "Web Chat". "instagram": "Instagram",
# For incoming messages, use the session's service name (Xmpp, Signal, Whatsapp, etc). "web": "Web Chat",
# 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: if is_outgoing:
source_label = "Web Chat" source_label = (
"Web Chat"
if not sender_uuid_value
else service_labels.get(
source_service, source_service.title() if source_service else "Unknown"
)
)
else: 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_label = service_labels.get(
source_service, source_service.title() if source_service else "Unknown" source_service, source_service.title() if source_service else "Unknown"
) )
@@ -2364,14 +2364,19 @@ class ComposeThread(LoginRequiredMixin, View):
seed_previous = ( seed_previous = (
base_queryset.filter(ts__lte=after_ts).order_by("-ts").first() base_queryset.filter(ts__lte=after_ts).order_by("-ts").first()
) )
queryset = queryset.filter(ts__gt=after_ts) # Use a small rolling window to capture late/out-of-order timestamps.
messages = list( # Client-side dedupe by message id prevents duplicate rendering.
window_start = max(0, int(after_ts) - 5 * 60 * 1000)
queryset = queryset.filter(ts__gte=window_start)
rows_desc = list(
queryset.select_related( queryset.select_related(
"session", "session",
"session__identifier", "session__identifier",
"session__identifier__person", "session__identifier__person",
).order_by("ts")[:limit] ).order_by("-ts")[:limit]
) )
rows_desc.reverse()
messages = rows_desc
newest = ( newest = (
Message.objects.filter( Message.objects.filter(
user=request.user, user=request.user,
@@ -2431,9 +2436,17 @@ class ComposeHistorySync(LoginRequiredMixin, View):
local = raw.split("@", 1)[0].strip() local = raw.split("@", 1)[0].strip()
if local: if local:
values.add(local) values.add(local)
elif service == "signal":
# Signal identifiers can be UUID or phone number
digits = re.sub(r"[^0-9]", "", raw)
if digits and not raw.count("-") >= 4:
# Likely a phone number; add variants
values.add(digits)
values.add(f"+{digits}")
# If it looks like a UUID (has hyphens), keep only the original format
# Signal UUIDs are strict and don't have variants
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,
@@ -2709,9 +2722,23 @@ class ComposeCommandResult(LoginRequiredMixin, View):
""" """
def get(self, request): def get(self, request):
timeout_s = 30.0
force_json = str(request.GET.get("format") or "").strip().lower() == "json"
is_hx_request = (
str(request.headers.get("HX-Request") or "").strip().lower() == "true"
) and not force_json
service = _default_service(request.GET.get("service")) service = _default_service(request.GET.get("service"))
command_id = str(request.GET.get("command_id") or "").strip() command_id = str(request.GET.get("command_id") or "").strip()
if not command_id: if not command_id:
if is_hx_request:
return render(
request,
"partials/compose-send-status.html",
{
"notice_message": "Missing command id.",
"notice_level": "warning",
},
)
return JsonResponse( return JsonResponse(
{"ok": False, "error": "missing_command_id"}, status=400 {"ok": False, "error": "missing_command_id"}, status=400
) )
@@ -2720,7 +2747,35 @@ class ComposeCommandResult(LoginRequiredMixin, View):
service, command_id, timeout=0.1 service, command_id, timeout=0.1
) )
if result is None: if result is None:
return JsonResponse({"pending": True}) age_s = transport.runtime_command_age_seconds(service, command_id)
if age_s is not None and age_s >= timeout_s:
timeout_result = {
"ok": False,
"error": f"runtime_command_timeout:{int(timeout_s)}s",
}
if is_hx_request:
return render(
request,
"partials/compose-send-status.html",
{
"notice_message": str(timeout_result.get("error") or "Send failed."),
"notice_level": "danger",
},
)
return JsonResponse({"pending": False, "result": timeout_result})
return HttpResponse(status=204)
if is_hx_request:
ok = bool(result.get("ok")) if isinstance(result, dict) else False
message = "" if ok else str((result or {}).get("error") or "Send failed.")
level = "success" if ok else "danger"
return render(
request,
"partials/compose-send-status.html",
{
"notice_message": message,
"notice_level": level,
},
)
return JsonResponse({"pending": False, "result": result}) return JsonResponse({"pending": False, "result": result})
@@ -3279,6 +3334,30 @@ class ComposeSend(LoginRequiredMixin, View):
ts = None ts = None
command_id = None command_id = None
if runtime_client is None: if runtime_client is None:
if base["service"] == "whatsapp":
runtime_state = transport.get_runtime_state("whatsapp")
last_seen = int(runtime_state.get("runtime_seen_at") or 0)
is_connected = bool(runtime_state.get("connected"))
pair_status = str(runtime_state.get("pair_status") or "").strip().lower()
now_s = int(time.time())
# Runtime may process sends even when `connected` lags false briefly;
# heartbeat freshness is the reliable signal for queue availability.
heartbeat_age = now_s - last_seen if last_seen > 0 else 10**9
runtime_healthy = bool(is_connected) or pair_status == "connected"
if (not runtime_healthy) and (last_seen <= 0 or heartbeat_age > 20):
logger.warning(
f"{log_prefix} runtime heartbeat stale (connected={is_connected}, pair_status={pair_status}, last_seen={last_seen}, age={heartbeat_age}); refusing queued send"
)
return self._response(
request,
ok=False,
message=(
"WhatsApp runtime is not connected right now. "
"Please wait for reconnect, then retry send."
),
level="warning",
panel_id=panel_id,
)
logger.info(f"{log_prefix} enqueuing runtime command (out-of-process)") logger.info(f"{log_prefix} enqueuing runtime command (out-of-process)")
command_id = transport.enqueue_runtime_command( command_id = transport.enqueue_runtime_command(
base["service"], base["service"],
@@ -3336,6 +3415,19 @@ class ComposeSend(LoginRequiredMixin, View):
logger.info( logger.info(
f"{log_prefix} created message id={msg.id} ts={msg_ts} delivered_ts={delivered_ts} custom_author=USER" f"{log_prefix} created message id={msg.id} ts={msg_ts} delivered_ts={delivered_ts} custom_author=USER"
) )
# Notify XMPP clients from runtime so cross-platform sends appear there too.
if base["service"] in {"signal", "whatsapp"}:
try:
transport.enqueue_runtime_command(
base["service"],
"notify_xmpp_sent",
{
"person_identifier_id": str(base["person_identifier"].id),
"text": text,
},
)
except Exception as exc:
logger.warning(f"{log_prefix} failed to enqueue xmpp notify: {exc}")
# If we enqueued, inform the client the message is queued and include command id. # If we enqueued, inform the client the message is queued and include command id.
if runtime_client is None: if runtime_client is None:

View File

@@ -36,6 +36,7 @@ services:
WHATSAPP_DB_DIR: "${WHATSAPP_DB_DIR}" WHATSAPP_DB_DIR: "${WHATSAPP_DB_DIR}"
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}" WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}" INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
COMPOSE_WS_ENABLED: "${COMPOSE_WS_ENABLED}"
XMPP_ADDRESS: "${XMPP_ADDRESS}" XMPP_ADDRESS: "${XMPP_ADDRESS}"
XMPP_JID: "${XMPP_JID}" XMPP_JID: "${XMPP_JID}"
XMPP_PORT: "${XMPP_PORT}" XMPP_PORT: "${XMPP_PORT}"
@@ -54,6 +55,54 @@ services:
# memory: 0.25G # memory: 0.25G
#network_mode: host #network_mode: host
asgi:
image: xf/gia:prod
container_name: asgi_gia
build:
context: .
args:
OPERATION: ${OPERATION}
command: sh -c 'rm -f /var/run/asgi-gia.sock && . /venv/bin/activate && python -m pip install --disable-pip-version-check -q uvicorn && python -m uvicorn app.asgi:application --uds /var/run/asgi-gia.sock --workers 1'
volumes:
- ${REPO_DIR}:/code
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
- gia_whatsapp_data:${WHATSAPP_DB_DIR}
- type: bind
source: /code/vrun
target: /var/run
environment:
APP_PORT: "${APP_PORT}"
REPO_DIR: "${REPO_DIR}"
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
DOMAIN: "${DOMAIN}"
URL: "${URL}"
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
DEBUG: "${DEBUG}"
SECRET_KEY: "${SECRET_KEY}"
STATIC_ROOT: "${STATIC_ROOT}"
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}"
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
SIGNAL_HTTP_URL: "${SIGNAL_HTTP_URL:-http://signal:8080}"
WHATSAPP_DB_DIR: "${WHATSAPP_DB_DIR}"
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
COMPOSE_WS_ENABLED: "${COMPOSE_WS_ENABLED}"
XMPP_ADDRESS: "${XMPP_ADDRESS}"
XMPP_JID: "${XMPP_JID}"
XMPP_PORT: "${XMPP_PORT}"
XMPP_SECRET: "${XMPP_SECRET}"
depends_on:
redis:
condition: service_healthy
migration:
condition: service_started
collectstatic:
condition: service_started
# giadb: # giadb:
# image: manticoresearch/manticore:dev # image: manticoresearch/manticore:dev
# container_name: giadb # container_name: giadb

View File

@@ -38,3 +38,4 @@ aiomysql
slixmpp slixmpp
neonize neonize
watchdog watchdog
uvicorn

View File

@@ -12,4 +12,5 @@ SECRET_KEY=
STATIC_ROOT=/code/static STATIC_ROOT=/code/static
REGISTRATION_OPEN=0 REGISTRATION_OPEN=0
OPERATION=uwsgi OPERATION=uwsgi
BILLING_ENABLED=0 BILLING_ENABLED=0
COMPOSE_WS_ENABLED=true