mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
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:
parent
ff50a88617
commit
96db7c6883
5 changed files with 179 additions and 5 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue