Fully implement WhatsApp, Signal and XMPP multiplexing
This commit is contained in:
@@ -354,29 +354,29 @@ def _serialize_message(msg: Message) -> dict:
|
||||
source_service = svc
|
||||
except Exception:
|
||||
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
|
||||
|
||||
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.
|
||||
# Outgoing messages created by the web compose UI should be labeled Web Chat.
|
||||
# Outgoing messages originating from platform runtimes (Signal sync, etc.)
|
||||
# should keep their service label.
|
||||
service_labels = {
|
||||
"xmpp": "XMPP",
|
||||
"whatsapp": "WhatsApp",
|
||||
"signal": "Signal",
|
||||
"instagram": "Instagram",
|
||||
"web": "Web Chat",
|
||||
}
|
||||
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:
|
||||
# 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"
|
||||
)
|
||||
@@ -2364,14 +2364,19 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
seed_previous = (
|
||||
base_queryset.filter(ts__lte=after_ts).order_by("-ts").first()
|
||||
)
|
||||
queryset = queryset.filter(ts__gt=after_ts)
|
||||
messages = list(
|
||||
# Use a small rolling window to capture late/out-of-order timestamps.
|
||||
# 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(
|
||||
"session",
|
||||
"session__identifier",
|
||||
"session__identifier__person",
|
||||
).order_by("ts")[:limit]
|
||||
).order_by("-ts")[:limit]
|
||||
)
|
||||
rows_desc.reverse()
|
||||
messages = rows_desc
|
||||
newest = (
|
||||
Message.objects.filter(
|
||||
user=request.user,
|
||||
@@ -2431,9 +2436,17 @@ class ComposeHistorySync(LoginRequiredMixin, View):
|
||||
local = raw.split("@", 1)[0].strip()
|
||||
if 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]
|
||||
|
||||
@classmethod
|
||||
@classmethod
|
||||
def _session_ids_for_scope(
|
||||
cls,
|
||||
@@ -2709,9 +2722,23 @@ class ComposeCommandResult(LoginRequiredMixin, View):
|
||||
"""
|
||||
|
||||
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"))
|
||||
command_id = str(request.GET.get("command_id") or "").strip()
|
||||
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(
|
||||
{"ok": False, "error": "missing_command_id"}, status=400
|
||||
)
|
||||
@@ -2720,7 +2747,35 @@ class ComposeCommandResult(LoginRequiredMixin, View):
|
||||
service, command_id, timeout=0.1
|
||||
)
|
||||
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})
|
||||
|
||||
|
||||
@@ -3279,6 +3334,30 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
ts = None
|
||||
command_id = 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)")
|
||||
command_id = transport.enqueue_runtime_command(
|
||||
base["service"],
|
||||
@@ -3336,6 +3415,19 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
logger.info(
|
||||
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 runtime_client is None:
|
||||
|
||||
Reference in New Issue
Block a user