diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 77d3c18cb6..a0053317f7 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -192,6 +192,15 @@ class SignalAdapter(BasePlatformAdapter): group_allowed_str = os.getenv("SIGNAL_GROUP_ALLOWED_USERS", "") self.group_allow_from = set(_parse_comma_list(group_allowed_str)) + # DM allowlist — mirrors SIGNAL_ALLOWED_USERS checked by run.py. + # Stored here so the reaction hooks can skip unauthorized senders + # (reactions fire before run.py's auth gate, so without this check + # every inbound DM from any contact gets a 👀 reaction). + # "*" means all users allowed (open mode); empty means no restriction + # recorded at adapter level (run.py still enforces auth separately). + dm_allowed_str = os.getenv("SIGNAL_ALLOWED_USERS", "*") + self.dm_allow_from = set(_parse_comma_list(dm_allowed_str)) + # HTTP client self.client: Optional[httpx.AsyncClient] = None @@ -1430,8 +1439,28 @@ class SignalAdapter(BasePlatformAdapter): return None return (author, ts) + def _reactions_enabled(self, event: "MessageEvent" = None) -> bool: + """Check if message reactions are enabled for this event. + + Two gates: + 1. SIGNAL_REACTIONS env var — set to false/0/no to disable globally. + 2. DM allowlist — if SIGNAL_ALLOWED_USERS is set, only react to + messages from senders in that list. This prevents unauthorized + contacts from seeing the 👀 reaction (which fires before run.py's + auth gate and would otherwise reveal that a bot is listening). + """ + if os.getenv("SIGNAL_REACTIONS", "true").lower() in ("false", "0", "no"): + return False + if event is not None: + sender = getattr(getattr(event, "source", None), "user_id", None) + if sender and "*" not in self.dm_allow_from and sender not in self.dm_allow_from: + return False + return True + async def on_processing_start(self, event: MessageEvent) -> None: """React with 👀 when processing begins.""" + if not self._reactions_enabled(event): + return target = self._extract_reaction_target(event) if target: await self.send_reaction(event.source.chat_id, "👀", *target) @@ -1442,6 +1471,8 @@ class SignalAdapter(BasePlatformAdapter): On CANCELLED we leave the 👀 in place — no terminal outcome means the reaction should keep reflecting "in progress" (matches Telegram). """ + if not self._reactions_enabled(event): + return if outcome == ProcessingOutcome.CANCELLED: return target = self._extract_reaction_target(event)