From ec9b868aea3984edde6abd4df470867322f53ed8 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 20 Mar 2026 04:46:32 -0700 Subject: [PATCH 1/2] fix(signal): handle Note to Self messages with echo-back protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support Signal 'Note to Self' messages in single-number setups where signal-cli is linked as a secondary device on the user's own account. syncMessage.sentMessage envelopes addressed to the bot's own account are now promoted to dataMessage for normal processing, while other sync events (read receipts, typing, etc.) are still filtered. Echo-back prevention mirrors the WhatsApp bridge pattern: - Track timestamps of recently sent messages (bounded set of 50) - When a Note to Self sync arrives, check if its timestamp matches a recent outbound — skip if so (agent echo-back) - Only process sync messages that are genuinely user-initiated Based on PR #2115 by @Stonelinks with added echo-back protection. --- gateway/platforms/signal.py | 42 ++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 2ce072ae3..4bedf4b07 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -179,6 +179,11 @@ class SignalAdapter(BasePlatformAdapter): # Normalize account for self-message filtering 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) + self._recent_sent_timestamps: set = set() + self._max_recent_timestamps = 50 + logger.info("Signal adapter initialized: url=%s account=%s groups=%s", self.http_url, _redact_phone(self.account), "enabled" if self.group_allow_from else "disabled") @@ -353,10 +358,26 @@ class SignalAdapter(BasePlatformAdapter): # Unwrap nested envelope if present envelope_data = envelope.get("envelope", envelope) - # Filter syncMessage envelopes (sent transcripts, read receipts, etc.) - # signal-cli may set syncMessage to null vs omitting it, so check key existence + # Handle syncMessage: extract "Note to Self" messages (sent to own account) + # while still filtering other sync events (read receipts, typing, etc.) + is_note_to_self = False if "syncMessage" in envelope_data: - return + sync_msg = envelope_data.get("syncMessage") + if sync_msg and isinstance(sync_msg, dict): + sent_msg = sync_msg.get("sentMessage") + if sent_msg and isinstance(sent_msg, dict): + dest = sent_msg.get("destinationNumber") or sent_msg.get("destination") + sent_ts = sent_msg.get("timestamp") + if dest == self._account_normalized: + # Check if this is an echo of our own outbound reply + if sent_ts and sent_ts in self._recent_sent_timestamps: + self._recent_sent_timestamps.discard(sent_ts) + return + # Genuine user Note to Self — promote to dataMessage + is_note_to_self = True + envelope_data = {**envelope_data, "dataMessage": sent_msg} + if not is_note_to_self: + return # Extract sender info sender = ( @@ -371,8 +392,8 @@ class SignalAdapter(BasePlatformAdapter): logger.debug("Signal: ignoring envelope with no sender") return - # Self-message filtering — prevent reply loops - if self._account_normalized and sender == self._account_normalized: + # Self-message filtering — prevent reply loops (but allow Note to Self) + if self._account_normalized and sender == self._account_normalized and not is_note_to_self: return # Filter stories @@ -577,9 +598,18 @@ class SignalAdapter(BasePlatformAdapter): result = await self._rpc("send", params) if result is not None: + self._track_sent_timestamp(result) return SendResult(success=True) return SendResult(success=False, error="RPC send failed") + def _track_sent_timestamp(self, rpc_result) -> None: + """Record outbound message timestamp for echo-back filtering.""" + ts = rpc_result.get("timestamp") if isinstance(rpc_result, dict) else None + if ts: + self._recent_sent_timestamps.add(ts) + if len(self._recent_sent_timestamps) > self._max_recent_timestamps: + self._recent_sent_timestamps.pop() + async def send_typing(self, chat_id: str, metadata=None) -> None: """Send a typing indicator.""" params: Dict[str, Any] = { @@ -635,6 +665,7 @@ class SignalAdapter(BasePlatformAdapter): result = await self._rpc("send", params) if result is not None: + self._track_sent_timestamp(result) return SendResult(success=True) return SendResult(success=False, error="RPC send with attachment failed") @@ -665,6 +696,7 @@ class SignalAdapter(BasePlatformAdapter): result = await self._rpc("send", params) if result is not None: + self._track_sent_timestamp(result) return SendResult(success=True) return SendResult(success=False, error="RPC send document failed") From cf29cba084a9d6485d748a48d0f6c404c2d3bc74 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 20 Mar 2026 04:48:13 -0700 Subject: [PATCH 2/2] docs(signal): add Note to Self section to Signal setup guide --- website/docs/user-guide/messaging/signal.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/website/docs/user-guide/messaging/signal.md b/website/docs/user-guide/messaging/signal.md index e1fd5463b..51d8f9629 100644 --- a/website/docs/user-guide/messaging/signal.md +++ b/website/docs/user-guide/messaging/signal.md @@ -177,6 +177,19 @@ All phone numbers are automatically redacted in logs: - `+15551234567` → `+155****4567` - This applies to both Hermes gateway logs and the global redaction system +### Note to Self (Single-Number Setup) + +If you run signal-cli as a **linked secondary device** on your own phone number (rather than a separate bot number), you can interact with Hermes through Signal's "Note to Self" feature. + +Just send a message to yourself from your phone — signal-cli picks it up and Hermes responds in the same conversation. + +**How it works:** +- "Note to Self" messages arrive as `syncMessage.sentMessage` envelopes +- The adapter detects when these are addressed to the bot's own account and processes them as regular inbound messages +- Echo-back protection (sent-timestamp tracking) prevents infinite loops — the bot's own replies are filtered out automatically + +**No extra configuration needed.** This works automatically as long as `SIGNAL_ACCOUNT` matches your phone number. + ### Health Monitoring The adapter monitors the SSE connection and automatically reconnects if: