fix(signal): preserve quoted reply context

Carry Signal quote metadata through gateway events so replies to assistant messages include the quoted context without personalizing comments.
This commit is contained in:
lkz-de 2026-06-15 02:52:39 +02:00 committed by kshitijk4poor
parent ff50a88617
commit 96db7c6883
5 changed files with 179 additions and 5 deletions

View file

@ -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.

View file

@ -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()

View file

@ -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:

View file

@ -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()

View file

@ -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):