From 0f36b2dde750894e35859378cec6cea5f7003aa4 Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Sun, 22 Feb 2026 11:03:08 +0000 Subject: [PATCH] Fix Signal compose live updates and self-chat direction --- core/clients/signal.py | 54 +++++++++++++++++++--- core/realtime/compose_ws.py | 27 +++++++++-- core/templates/partials/compose-panel.html | 25 +++++++++- core/views/compose.py | 31 ++++++++++++- 4 files changed, 124 insertions(+), 13 deletions(-) diff --git a/core/clients/signal.py b/core/clients/signal.py index 4fa1fee..fe44b75 100644 --- a/core/clients/signal.py +++ b/core/clients/signal.py @@ -1,5 +1,6 @@ import asyncio import json +import re import time from urllib.parse import quote_plus, urlparse @@ -198,10 +199,18 @@ def _identifier_candidates(*values): seen = set() for value in values: cleaned = str(value or "").strip() - if not cleaned or cleaned in seen: + if not cleaned: continue - seen.add(cleaned) - out.append(cleaned) + candidates = [cleaned] + digits = re.sub(r"[^0-9]", "", cleaned) + # Add basic E.164 variants for phone-shaped values. + if digits and cleaned.count("-") < 4: + candidates.extend([digits, f"+{digits}"]) + for candidate in candidates: + if not candidate or candidate in seen: + continue + seen.add(candidate) + out.append(candidate) return out @@ -349,12 +358,32 @@ class HandleMessage(Command): ts = c.message.timestamp source_value = c.message.source envelope = raw.get("envelope", {}) + destination_number = sent_message.get("destination") + + bot_uuid = str(getattr(c.bot, "bot_uuid", "") or "").strip() + bot_phone = str(getattr(c.bot, "phone_number", "") or "").strip() + source_uuid_norm = str(source_uuid or "").strip() + source_number_norm = str(source_number or "").strip() + dest_norm = str(dest or "").strip() + destination_number_norm = str(destination_number or "").strip() + + bot_phone_digits = re.sub(r"[^0-9]", "", bot_phone) + source_phone_digits = re.sub(r"[^0-9]", "", source_number_norm) + dest_phone_digits = re.sub(r"[^0-9]", "", destination_number_norm or dest_norm) # Message originating from us same_recipient = source_uuid == dest - is_from_bot = source_uuid == c.bot.bot_uuid - is_to_bot = dest == c.bot.bot_uuid or dest is None + is_from_bot = bool(bot_uuid and source_uuid_norm and source_uuid_norm == bot_uuid) + if (not is_from_bot) and bot_phone_digits and source_phone_digits: + is_from_bot = source_phone_digits == bot_phone_digits + + # For non-sync incoming events destination is usually absent and points to us. + is_to_bot = bool(bot_uuid and dest_norm and dest_norm == bot_uuid) + if (not is_to_bot) and bot_phone_digits and dest_phone_digits: + is_to_bot = dest_phone_digits == bot_phone_digits + if (not is_to_bot) and (not dest_norm) and (not destination_number_norm): + is_to_bot = True reply_to_self = same_recipient and is_from_bot # Reply reply_to_others = is_to_bot and not same_recipient # Reply @@ -363,7 +392,6 @@ class HandleMessage(Command): envelope_source_uuid = envelope.get("sourceUuid") envelope_source_number = envelope.get("sourceNumber") envelope_source = envelope.get("source") - destination_number = sent_message.get("destination") primary_identifier = dest if is_from_bot else source_uuid if dest or destination_number: @@ -450,6 +478,20 @@ class HandleMessage(Command): len(identifiers), ) for identifier in identifiers: + try: + await history.apply_reaction( + identifier.user, + identifier, + target_message_id="", + target_ts=int(reaction_payload.get("target_ts") or 0), + emoji=str(reaction_payload.get("emoji") or ""), + source_service="signal", + actor=(source_uuid or source_number or ""), + remove=bool(reaction_payload.get("remove")), + payload=reaction_payload.get("raw") or {}, + ) + except Exception as exc: + log.warning("Signal reaction history apply failed: %s", exc) try: await self.ur.xmpp.client.apply_external_reaction( identifier.user, diff --git a/core/realtime/compose_ws.py b/core/realtime/compose_ws.py index cf53eac..1e31583 100644 --- a/core/realtime/compose_ws.py +++ b/core/realtime/compose_ws.py @@ -22,6 +22,24 @@ def _safe_int(value, default=0): return default +def _message_fingerprint(message_row): + row = dict(message_row or {}) + fields = { + "id": str(row.get("id") or ""), + "ts": int(_safe_int(row.get("ts"), 0)), + "text": str(row.get("text") or ""), + "display_text": str(row.get("display_text") or ""), + "outgoing": bool(row.get("outgoing")), + "read_ts": int(_safe_int(row.get("read_ts"), 0)), + "delivered_ts": int(_safe_int(row.get("delivered_ts"), 0)), + "receipt_payload": row.get("receipt_payload") or {}, + "reactions": row.get("reactions") or [], + "source_service": str(row.get("source_service") or ""), + "source_label": str(row.get("source_label") or ""), + } + return json.dumps(fields, sort_keys=True, separators=(",", ":")) + + def _load_since(user_id, service, identifier, person_id, after_ts, limit): person = None person_identifier = None @@ -174,7 +192,7 @@ async def compose_ws_application(scope, receive, send): last_ts = 0 limit = 100 last_typing_key = "" - sent_message_ids = set() + sent_message_state = {} while True: event = None @@ -205,10 +223,11 @@ async def compose_ws_application(scope, receive, send): 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) + fingerprint = _message_fingerprint(msg) + if sent_message_state.get(message_id) == fingerprint: + continue + sent_message_state[message_id] = fingerprint messages.append(msg) latest = _safe_int(payload.get("last_ts"), last_ts) if resolved_person_id <= 0: diff --git a/core/templates/partials/compose-panel.html b/core/templates/partials/compose-panel.html index 71603f4..b1d1b22 100644 --- a/core/templates/partials/compose-panel.html +++ b/core/templates/partials/compose-panel.html @@ -2047,8 +2047,13 @@ const appendBubble = function (msg) { const messageId = String(msg && msg.id ? msg.id : "").trim(); - if (messageId && panelState.seenMessageIds.has(messageId)) { - return; + if (messageId) { + const existingRow = Array.from(thread.querySelectorAll(".compose-row")).find(function (row) { + return String((row.dataset && row.dataset.messageId) || "") === messageId; + }); + if (existingRow) { + existingRow.remove(); + } } const row = document.createElement("div"); const outgoing = !!msg.outgoing; @@ -2114,6 +2119,22 @@ fallback.textContent = "(no text)"; bubble.appendChild(fallback); } + if (Array.isArray(msg.reactions) && msg.reactions.length) { + const reactionsWrap = document.createElement("div"); + reactionsWrap.className = "compose-reactions"; + reactionsWrap.setAttribute("aria-label", "Message reactions"); + msg.reactions.forEach(function (reaction) { + const chip = document.createElement("span"); + chip.className = "compose-reaction-chip"; + chip.textContent = String(reaction && reaction.emoji ? reaction.emoji : ""); + chip.title = + String((reaction && reaction.actor) || "Unknown") + + " via " + + String((reaction && reaction.source_service) || "unknown").toUpperCase(); + reactionsWrap.appendChild(chip); + }); + bubble.appendChild(reactionsWrap); + } const meta = document.createElement("p"); meta.className = "compose-msg-meta"; diff --git a/core/views/compose.py b/core/views/compose.py index b44e2b0..30855d9 100644 --- a/core/views/compose.py +++ b/core/views/compose.py @@ -129,7 +129,36 @@ def _format_ts_label(ts_value: int) -> str: def _is_outgoing(msg: Message) -> bool: - return str(msg.custom_author or "").upper() in {"USER", "BOT"} + is_outgoing = str(msg.custom_author or "").upper() in {"USER", "BOT"} + if not is_outgoing: + return False + + # Signal self-chat special case: + # platform-originated Signal sync events can carry our own sender id and + # are currently stored as USER; render them as counterpart-side so the + # thread reads naturally when messaging ourselves. + try: + session = getattr(msg, "session", None) + identifier_obj = getattr(session, "identifier", None) + service = str(getattr(identifier_obj, "service", "") or "").strip().lower() + if service != "signal": + return True + + sender_uuid = str(getattr(msg, "sender_uuid", "") or "").strip() + identifier_value = str(getattr(identifier_obj, "identifier", "") or "").strip() + if not sender_uuid or not identifier_value: + return True + + if sender_uuid.lower() == identifier_value.lower(): + return False + sender_digits = re.sub(r"[^0-9]", "", sender_uuid) + identifier_digits = re.sub(r"[^0-9]", "", identifier_value) + if sender_digits and identifier_digits and sender_digits == identifier_digits: + return False + except Exception: + return is_outgoing + + return True def _clean_url(candidate: str) -> str: