From 7165eff901c52dc53e9917bb6ef254be2bfb2038 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 3 Apr 2026 08:13:07 +0530 Subject: [PATCH] fix(whatsapp): add free_response_chats, mention stripping, and interactive message unwrapping Address feature gaps vs Telegram/Discord/Mattermost adapters: - free_response_chats whitelist to bypass mention gating per-group - strip bot @phone mentions from body before forwarding to agent - unwrap templateMessage/buttonsMessage/listMessage in bridge - info-level log on successful mention pattern compilation - use module-level json import instead of inline import in config - eliminate double _normalize_whatsapp_id call via walrus operator - hoist botIds computation outside per-message loop in bridge --- gateway/config.py | 8 +++- gateway/platforms/whatsapp.py | 30 ++++++++++++- scripts/whatsapp-bridge/bridge.js | 12 ++++-- tests/gateway/test_whatsapp_group_gating.py | 47 ++++++++++++++++++++- 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index e7794b75..1896db9f 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -569,8 +569,12 @@ def load_gateway_config() -> GatewayConfig: if "require_mention" in whatsapp_cfg and not os.getenv("WHATSAPP_REQUIRE_MENTION"): os.environ["WHATSAPP_REQUIRE_MENTION"] = str(whatsapp_cfg["require_mention"]).lower() if "mention_patterns" in whatsapp_cfg and not os.getenv("WHATSAPP_MENTION_PATTERNS"): - import json as _json - os.environ["WHATSAPP_MENTION_PATTERNS"] = _json.dumps(whatsapp_cfg["mention_patterns"]) + os.environ["WHATSAPP_MENTION_PATTERNS"] = json.dumps(whatsapp_cfg["mention_patterns"]) + frc = whatsapp_cfg.get("free_response_chats") + if frc is not None and not os.getenv("WHATSAPP_FREE_RESPONSE_CHATS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc) except Exception as e: logger.warning( "Failed to process config.yaml — falling back to .env / gateway.json values. " diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index fb5d1b2d..ac94e472 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -156,6 +156,14 @@ class WhatsAppAdapter(BasePlatformAdapter): return bool(configured) return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + def _whatsapp_free_response_chats(self) -> set[str]: + raw = self.config.extra.get("free_response_chats") + if raw is None: + raw = os.getenv("WHATSAPP_FREE_RESPONSE_CHATS", "") + if isinstance(raw, list): + return {str(part).strip() for part in raw if str(part).strip()} + return {part.strip() for part in str(raw).split(",") if part.strip()} + def _compile_mention_patterns(self): patterns = self.config.extra.get("mention_patterns") if patterns is None: @@ -183,6 +191,8 @@ class WhatsAppAdapter(BasePlatformAdapter): compiled.append(re.compile(pattern, re.IGNORECASE)) except re.error as exc: logger.warning("[%s] Invalid WhatsApp mention pattern %r: %s", self.name, pattern, exc) + if compiled: + logger.info("[%s] Loaded %d WhatsApp mention pattern(s)", self.name, len(compiled)) return compiled @staticmethod @@ -213,9 +223,9 @@ class WhatsAppAdapter(BasePlatformAdapter): if not bot_ids: return False mentioned_ids = { - self._normalize_whatsapp_id(candidate) + nid for candidate in (data.get("mentionedIds") or []) - if self._normalize_whatsapp_id(candidate) + if (nid := self._normalize_whatsapp_id(candidate)) } if mentioned_ids & bot_ids: return True @@ -234,9 +244,23 @@ class WhatsAppAdapter(BasePlatformAdapter): body = str(data.get("body") or "") return any(pattern.search(body) for pattern in self._mention_patterns) + def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str: + if not text: + return text + bot_ids = self._bot_ids_from_message(data) + cleaned = text + for bot_id in bot_ids: + bare_id = bot_id.split("@", 1)[0] + if bare_id: + cleaned = re.sub(rf"@{re.escape(bare_id)}\b[,:\-]*\s*", "", cleaned) + return cleaned.strip() or text + def _should_process_message(self, data: Dict[str, Any]) -> bool: if not data.get("isGroup"): return True + chat_id = str(data.get("chatId") or "") + if chat_id in self._whatsapp_free_response_chats(): + return True if not self._whatsapp_require_mention(): return True body = str(data.get("body") or "").strip() @@ -874,6 +898,8 @@ class WhatsAppAdapter(BasePlatformAdapter): # the message text so the agent can read it inline. # Cap at 100KB to match Telegram/Discord/Slack behaviour. body = data.get("body", "") + if data.get("isGroup"): + body = self._clean_bot_mention_text(body, data) MAX_TEXT_INJECT_BYTES = 100 * 1024 if msg_type == MessageType.DOCUMENT and cached_urls: for doc_path in cached_urls: diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index c4d6891c..70cf8e95 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -73,6 +73,9 @@ function getMessageContent(msg) { if (content.viewOnceMessage?.message) return content.viewOnceMessage.message; if (content.viewOnceMessageV2?.message) return content.viewOnceMessageV2.message; if (content.documentWithCaptionMessage?.message) return content.documentWithCaptionMessage.message; + if (content.templateMessage?.hydratedTemplate) return content.templateMessage.hydratedTemplate; + if (content.buttonsMessage) return content.buttonsMessage; + if (content.listMessage) return content.listMessage; return content; } @@ -181,6 +184,11 @@ async function startSocket() { // than 'notify'. Accept both and filter agent echo-backs below. if (type !== 'notify' && type !== 'append') return; + const botIds = Array.from(new Set([ + normalizeWhatsAppId(sock.user?.id), + normalizeWhatsAppId(sock.user?.lid), + ].filter(Boolean))); + for (const msg of messages) { if (!msg.message) continue; @@ -228,10 +236,6 @@ async function startSocket() { const contextInfo = getContextInfo(messageContent); const mentionedIds = Array.from(new Set((contextInfo?.mentionedJid || []).map(normalizeWhatsAppId).filter(Boolean))); const quotedParticipant = normalizeWhatsAppId(contextInfo?.participant || contextInfo?.remoteJid || ''); - const botIds = Array.from(new Set([ - normalizeWhatsAppId(sock.user?.id), - normalizeWhatsAppId(sock.user?.lid), - ].filter(Boolean))); // Extract message body let body = ''; diff --git a/tests/gateway/test_whatsapp_group_gating.py b/tests/gateway/test_whatsapp_group_gating.py index 8d1c3d6d..87caa46b 100644 --- a/tests/gateway/test_whatsapp_group_gating.py +++ b/tests/gateway/test_whatsapp_group_gating.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from gateway.config import Platform, PlatformConfig, load_gateway_config -def _make_adapter(require_mention=None, mention_patterns=None): +def _make_adapter(require_mention=None, mention_patterns=None, free_response_chats=None): from gateway.platforms.whatsapp import WhatsAppAdapter extra = {} @@ -12,6 +12,8 @@ def _make_adapter(require_mention=None, mention_patterns=None): extra["require_mention"] = require_mention if mention_patterns is not None: extra["mention_patterns"] = mention_patterns + if free_response_chats is not None: + extra["free_response_chats"] = free_response_chats adapter = object.__new__(WhatsAppAdapter) adapter.platform = Platform.WHATSAPP @@ -25,6 +27,7 @@ def _group_message(body="hello", **overrides): data = { "isGroup": True, "body": body, + "chatId": "120363001234567890@g.us", "mentionedIds": [], "botIds": ["15551230000@s.whatsapp.net", "15551230000@lid"], "quotedParticipant": "", @@ -95,3 +98,45 @@ def test_config_bridges_whatsapp_group_settings(monkeypatch, tmp_path): assert config.platforms[Platform.WHATSAPP].extra["mention_patterns"] == [r"^\s*chompy\b"] assert __import__("os").environ["WHATSAPP_REQUIRE_MENTION"] == "true" assert json.loads(__import__("os").environ["WHATSAPP_MENTION_PATTERNS"]) == [r"^\s*chompy\b"] + + +def test_free_response_chats_bypass_mention_gating(): + adapter = _make_adapter( + require_mention=True, + free_response_chats=["120363001234567890@g.us"], + ) + + assert adapter._should_process_message(_group_message("hello everyone")) is True + + +def test_free_response_chats_does_not_bypass_other_groups(): + adapter = _make_adapter( + require_mention=True, + free_response_chats=["999999999999@g.us"], + ) + + assert adapter._should_process_message(_group_message("hello everyone")) is False + + +def test_dm_always_passes_even_with_require_mention(): + adapter = _make_adapter(require_mention=True) + + dm = {"isGroup": False, "body": "hello", "botIds": [], "mentionedIds": []} + assert adapter._should_process_message(dm) is True + + +def test_mention_stripping_removes_bot_phone_from_body(): + adapter = _make_adapter(require_mention=True) + + data = _group_message("@15551230000 what is the weather?") + cleaned = adapter._clean_bot_mention_text(data["body"], data) + assert "15551230000" not in cleaned + assert "weather" in cleaned + + +def test_mention_stripping_preserves_body_when_no_mention(): + adapter = _make_adapter(require_mention=True) + + data = _group_message("just a normal message") + cleaned = adapter._clean_bot_mention_text(data["body"], data) + assert cleaned == "just a normal message"