diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index cda3acc6e58..8c447a7a2bf 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1454,6 +1454,9 @@ class MessageEvent: # Reply context reply_to_message_id: Optional[str] = None reply_to_text: Optional[str] = None # Text of the replied-to message (for context injection) + reply_to_author_id: Optional[str] = None + reply_to_author_name: Optional[str] = None + reply_to_is_own_message: bool = False # True when the user replied to this bot/assistant's message # Auto-loaded skill(s) for topic/channel bindings (e.g., Telegram DM Topics, # Discord channel_skill_bindings). A single name or ordered list. diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 7b81b2a957a..860f6468818 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -304,9 +304,15 @@ class SignalAdapter(BasePlatformAdapter): self._account_normalized = self.account.strip() # Track recently sent message timestamps to prevent echo-back loops - # in Note to Self / self-chat mode (mirrors WhatsApp recentlySentIds) + # in Note to Self / self-chat mode (mirrors WhatsApp recentlySentIds). self._recent_sent_timestamps: set = set() self._max_recent_timestamps = 50 + # Keep a separate bounded cache of outbound Signal message timestamps. + # Signal quote.id is the timestamp of the quoted message, so this lets + # inbound replies identify that the user replied to a message sent by + # this bot even after the self-sync echo was filtered above. + self._sent_message_timestamps: set[str] = set() + self._max_sent_message_timestamps = 500 # Signal increasingly exposes ACI/PNI UUIDs as stable recipient IDs. # Keep a best-effort mapping so outbound sends can upgrade from a # phone number to the corresponding UUID when signal-cli prefers it. @@ -615,10 +621,16 @@ class SignalAdapter(BasePlatformAdapter): ) return - # Extract quote (reply-to) context from Signal dataMessage + # Extract quote (reply-to) context from Signal dataMessage. Signal's + # quote.id is the timestamp of the quoted message; quote.author points + # at the quoted sender when available. Preserve both so the gateway can + # tell the agent when the user replied to a specific assistant message. quote_data = data_message.get("quote") or {} reply_to_id = str(quote_data.get("id")) if quote_data.get("id") else None reply_to_text = quote_data.get("text") + reply_to_author = self._extract_quote_author(quote_data) + reply_to_author_name = quote_data.get("authorName") or quote_data.get("authorProfileName") + reply_to_is_own = self._quote_references_own_message(reply_to_id, reply_to_author) # Process attachments attachments_data = data_message.get("attachments", []) @@ -703,9 +715,16 @@ class SignalAdapter(BasePlatformAdapter): media_urls=media_urls, media_types=media_types, timestamp=timestamp, - raw_message={"sender": sender, "timestamp_ms": ts_ms}, + raw_message={ + "sender": sender, + "timestamp_ms": ts_ms, + "quote": quote_data if quote_data else None, + }, reply_to_message_id=reply_to_id, reply_to_text=reply_to_text, + reply_to_author_id=reply_to_author, + reply_to_author_name=reply_to_author_name, + reply_to_is_own_message=reply_to_is_own, ) logger.debug("Signal: message from %s in %s: %s", @@ -720,6 +739,51 @@ class SignalAdapter(BasePlatformAdapter): self._recipient_uuid_by_number[number] = service_id self._recipient_number_by_uuid[service_id] = number + @staticmethod + def _extract_quote_author(quote_data: Any) -> Optional[str]: + """Return the best available Signal sender identifier from quote metadata.""" + if not isinstance(quote_data, dict): + return None + for key in ( + "author", + "authorNumber", + "authorUuid", + "authorAci", + "authorServiceId", + "authorServiceIdString", + ): + value = quote_data.get(key) + if value: + return str(value) + return None + + def _quote_references_own_message( + self, + reply_to_id: Optional[str], + reply_to_author: Optional[str], + ) -> bool: + """True when a Signal quote points at this adapter's outbound message.""" + if reply_to_id and str(reply_to_id) in self._sent_message_timestamps: + return True + if not reply_to_author: + return False + author = str(reply_to_author).strip() + if self._account_normalized and author == self._account_normalized: + return True + cached_uuid = self._recipient_uuid_by_number.get(self._account_normalized) + if cached_uuid and author == cached_uuid: + return True + cached_number = self._recipient_number_by_uuid.get(author) + return bool(cached_number and cached_number == self._account_normalized) + + def _remember_sent_message_timestamp(self, timestamp: Any) -> None: + """Keep a bounded cache of outbound Signal timestamps for quote matching.""" + if timestamp is None: + return + self._sent_message_timestamps.add(str(timestamp)) + if len(self._sent_message_timestamps) > self._max_sent_message_timestamps: + self._sent_message_timestamps.pop() + def _extract_contact_uuid(self, contact: Any, phone_number: str) -> Optional[str]: """Best-effort extraction of a Signal service ID from listContacts output.""" if not isinstance(contact, dict): @@ -992,6 +1056,7 @@ class SignalAdapter(BasePlatformAdapter): ts = rpc_result.get("timestamp") if isinstance(rpc_result, dict) else None if ts: self._recent_sent_timestamps.add(ts) + self._remember_sent_message_timestamp(ts) if len(self._recent_sent_timestamps) > self._max_recent_timestamps: self._recent_sent_timestamps.pop() diff --git a/gateway/run.py b/gateway/run.py index 673ec3e3994..4874c28a08b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8658,7 +8658,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # multiple times, and without an explicit pointer the agent has to # guess (or answer for both subjects). Token overhead is minimal. reply_snippet = event.reply_to_text[:500] - message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}' + if getattr(event, "reply_to_is_own_message", False): + message_text = ( + f'[Replying to your previous message: "{reply_snippet}"]\n\n' + f"{message_text}" + ) + else: + message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}' if "@" in message_text: try: diff --git a/tests/gateway/test_reply_to_injection.py b/tests/gateway/test_reply_to_injection.py index f75ec6d68f3..311a18cc06b 100644 --- a/tests/gateway/test_reply_to_injection.py +++ b/tests/gateway/test_reply_to_injection.py @@ -99,6 +99,29 @@ async def test_reply_prefix_still_injected_when_text_in_history(): assert result.endswith("What's the best time to go?") +@pytest.mark.asyncio +async def test_own_message_reply_prefix_marks_assistant_message(): + runner = _make_runner() + source = _source() + event = MessageEvent( + text="this one", + source=source, + reply_to_message_id="42", + reply_to_text="Use the direct train.", + reply_to_is_own_message=True, + ) + + result = await runner._prepare_inbound_message_text( + event=event, + source=source, + history=[], + ) + + assert result is not None + assert result.startswith('[Replying to your previous message: "Use the direct train."]') + assert result.endswith("this one") + + @pytest.mark.asyncio async def test_no_prefix_without_reply_context(): runner = _make_runner() diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index e79ee7a8591..5a3d8c6b738 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -69,6 +69,7 @@ class TestSignalConfigLoading: def test_signal_not_loaded_without_both_vars(self, monkeypatch): monkeypatch.setenv("SIGNAL_HTTP_URL", "http://localhost:9090") + monkeypatch.delenv("SIGNAL_ACCOUNT", raising=False) # No SIGNAL_ACCOUNT from gateway.config import GatewayConfig, _apply_env_overrides @@ -1380,7 +1381,7 @@ class TestSignalQuoteExtraction: "quote": { "id": 99, "text": "want to grab lunch?", - "author": "+15550002222", + "author": "other-author", }, }, } @@ -1390,6 +1391,82 @@ class TestSignalQuoteExtraction: assert event.text == "yes I agree" assert event.reply_to_message_id == "99" assert event.reply_to_text == "want to grab lunch?" + assert event.reply_to_author_id == "other-author" + assert event.reply_to_is_own_message is False + + @pytest.mark.asyncio + async def test_handle_envelope_marks_quote_to_own_sent_timestamp(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch) + adapter._remember_sent_message_timestamp(424242) + captured = {} + + async def fake_handle(event): + captured["event"] = event + + adapter.handle_message = fake_handle + + await adapter._handle_envelope({ + "envelope": { + "sourceNumber": "+155****1111", + "sourceUuid": "uuid-sender", + "sourceName": "Tester", + "timestamp": 1000000000, + "dataMessage": { + "message": "this specific one", + "quote": { + "id": 424242, + "text": "assistant answer", + "author": "other-author", + }, + }, + } + }) + + event = captured["event"] + assert event.reply_to_message_id == "424242" + assert event.reply_to_text == "assistant answer" + assert event.reply_to_author_id == "other-author" + assert event.reply_to_is_own_message is True + + @pytest.mark.asyncio + async def test_handle_envelope_marks_quote_to_own_account_author(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch, account="bot-author") + captured = {} + + async def fake_handle(event): + captured["event"] = event + + adapter.handle_message = fake_handle + + await adapter._handle_envelope({ + "envelope": { + "sourceNumber": "+155****1111", + "sourceUuid": "uuid-sender", + "sourceName": "Tester", + "timestamp": 1000000000, + "dataMessage": { + "message": "reply by author", + "quote": { + "id": 777, + "text": "assistant answer", + "author": "bot-author", + }, + }, + } + }) + + event = captured["event"] + assert event.reply_to_message_id == "777" + assert event.reply_to_is_own_message is True + + @pytest.mark.asyncio + async def test_track_sent_timestamp_keeps_reply_detection_cache_after_echo_discard(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch) + adapter._track_sent_timestamp({"timestamp": 111222333}) + adapter._recent_sent_timestamps.discard(111222333) + + assert "111222333" in adapter._sent_message_timestamps + assert adapter._quote_references_own_message("111222333", None) is True @pytest.mark.asyncio async def test_handle_envelope_without_quote_leaves_reply_fields_none(self, monkeypatch):