mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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.
This commit is contained in:
parent
b08f53a758
commit
9ed751b967
2 changed files with 103 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue