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:
@@ -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. "
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user