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