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
This commit is contained in:
kshitijk4poor
2026-04-03 08:13:07 +05:30
committed by Teknium
parent 714e4941b8
commit 7165eff901
4 changed files with 88 additions and 9 deletions

View File

@@ -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. "

View File

@@ -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:

View File

@@ -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 = '';

View File

@@ -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"