From 9ed751b96706ffd343ae26531cd0e2152a1c7036 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 09:59:03 -0700 Subject: [PATCH] fix(whatsapp): drop status broadcasts and channel newsletters before agent dispatch (#25845) WhatsApp pseudo-chats (Status updates / Stories, Channels / Newsletters, broadcast lists) were being routed through the full agent pipeline. A user's gateway.log showed the agent replying to a contact's Story ('status@broadcast') with 345 chars plus title-generation cost, which also shows up in the contact's status feed. Drop these JIDs at _should_process_message() before the policy gate so they're filtered regardless of dm_policy or allowlist state. Covers: - status@broadcast (Stories) - *@newsletter (Channels) - *@broadcast (broadcast lists, future-proofing) The bridge.js already filters these on the fromMe outbound path, but inbound events on self-chat mode skipped that check. Tests: - status@broadcast dropped on open policy - broadcast filter wins over allowlisted senders - real DMs still pass through - helper unit cases (case-insensitive, whitespace-tolerant) 26/26 tests/gateway/test_whatsapp_group_gating.py pass; 59/59 adjacent WhatsApp test suites pass. --- gateway/platforms/whatsapp.py | 29 +++++++- tests/gateway/test_whatsapp_group_gating.py | 75 +++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 29b78d75d01..5239df3b5ae 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -322,6 +322,26 @@ class WhatsAppAdapter(BasePlatformAdapter): return {str(part).strip() for part in raw if str(part).strip()} return {part.strip() for part in str(raw).split(",") if part.strip()} + @staticmethod + def _is_broadcast_chat(chat_id: str) -> bool: + """True for WhatsApp pseudo-chats that aren't real conversations. + + Covers Status updates (Stories) and Channel/Newsletter broadcasts. + These show up as inbound messages on Baileys but the agent should + never reply — answering a Story update spams the contact's status + feed, and Channel posts aren't addressable in the first place. + """ + if not chat_id: + return False + cid = chat_id.strip().lower() + if cid == "status@broadcast": + return True + # @broadcast suffix covers status@broadcast plus any future + # broadcast-list variants. @newsletter is the Channel JID suffix. + if cid.endswith("@broadcast") or cid.endswith("@newsletter"): + return True + return False + def _is_dm_allowed(self, sender_id: str) -> bool: """Check whether a DM from the given sender should be processed.""" if self._dm_policy == "disabled": @@ -432,9 +452,16 @@ class WhatsAppAdapter(BasePlatformAdapter): return cleaned.strip() or text def _should_process_message(self, data: Dict[str, Any]) -> bool: + chat_id_raw = str(data.get("chatId") or "") + # WhatsApp uses pseudo-chats for Status updates (Stories) and + # Channel/Newsletter broadcasts. These are not real conversations + # and the agent should never reply to them — even in self-chat mode + # where the bridge may surface them as "fromMe" events. + if self._is_broadcast_chat(chat_id_raw): + return False is_group = data.get("isGroup", False) if is_group: - chat_id = str(data.get("chatId") or "") + chat_id = chat_id_raw if not self._is_group_allowed(chat_id): return False else: diff --git a/tests/gateway/test_whatsapp_group_gating.py b/tests/gateway/test_whatsapp_group_gating.py index afe974320c9..206c75830b7 100644 --- a/tests/gateway/test_whatsapp_group_gating.py +++ b/tests/gateway/test_whatsapp_group_gating.py @@ -296,3 +296,78 @@ def test_config_bridges_whatsapp_allow_from(monkeypatch, tmp_path): assert config.platforms[Platform.WHATSAPP].extra["allow_from"] == ["6281234567890@s.whatsapp.net"] assert __import__("os").environ["WHATSAPP_DM_POLICY"] == "allowlist" assert __import__("os").environ["WHATSAPP_ALLOWED_USERS"] == "6281234567890@s.whatsapp.net" + + +# --- Broadcast / status / newsletter pseudo-chats are always dropped --- + + +def test_status_broadcast_chats_are_always_dropped(): + """Felipe's gateway.log showed the agent replying to status@broadcast + (a contact's WhatsApp Story update). These pseudo-chats aren't real + conversations and the adapter must drop them regardless of dm_policy. + """ + from gateway.platforms.whatsapp import WhatsAppAdapter + + # Even on the most permissive config — open DMs, no allowlist — Stories + # and Channel posts must not reach the agent. + adapter = _make_adapter(dm_policy="open") + + # Classic Story update — what Felipe was seeing in production. + status_msg = _dm_message( + body="[video received]", + chatId="status@broadcast", + senderId="34612345678@s.whatsapp.net", + ) + assert adapter._should_process_message(status_msg) is False + + # Channel / Newsletter broadcast posts. + newsletter_msg = _dm_message( + body="check out our latest post", + chatId="120363999999999999@newsletter", + senderId="120363999999999999@newsletter", + ) + assert adapter._should_process_message(newsletter_msg) is False + + +def test_broadcast_filter_runs_before_allowlist(): + """A status@broadcast message from an allowlisted sender still drops — + we never want to reply to Stories, even from authorized contacts. + """ + adapter = _make_adapter( + dm_policy="allowlist", + allow_from=["34612345678@s.whatsapp.net"], + ) + + msg = _dm_message( + body="[image received]", + chatId="status@broadcast", + senderId="34612345678@s.whatsapp.net", + ) + assert adapter._should_process_message(msg) is False + + +def test_real_dm_still_processed_after_broadcast_filter(): + """Sanity check: the broadcast filter doesn't accidentally drop real DMs.""" + adapter = _make_adapter(dm_policy="open") + + msg = _dm_message( + body="hello", + chatId="34612345678@s.whatsapp.net", + senderId="34612345678@s.whatsapp.net", + ) + assert adapter._should_process_message(msg) is True + + +def test_is_broadcast_chat_helper_recognizes_common_jids(): + from gateway.platforms.whatsapp import WhatsAppAdapter + + assert WhatsAppAdapter._is_broadcast_chat("status@broadcast") is True + assert WhatsAppAdapter._is_broadcast_chat("STATUS@BROADCAST") is True + assert WhatsAppAdapter._is_broadcast_chat(" status@broadcast ") is True + assert WhatsAppAdapter._is_broadcast_chat("120363999999999999@newsletter") is True + assert WhatsAppAdapter._is_broadcast_chat("1234@broadcast") is True # broadcast list + # Real chats must not match. + assert WhatsAppAdapter._is_broadcast_chat("34612345678@s.whatsapp.net") is False + assert WhatsAppAdapter._is_broadcast_chat("120363001234567890@g.us") is False + assert WhatsAppAdapter._is_broadcast_chat("") is False + assert WhatsAppAdapter._is_broadcast_chat(None) is False # type: ignore[arg-type]