From 7972ff2a2cd2b56358ae7596d9ad4218b80b9984 Mon Sep 17 00:00:00 2001 From: MassiveMassimo Date: Mon, 20 Apr 2026 11:54:40 -0700 Subject: [PATCH] feat(whatsapp): add dm_policy and group_policy parity with WeCom/Weixin/QQ adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add dm_policy and group_policy to the WhatsApp adapter, bringing parity with WeCom/Weixin/QQ. Allows independent control of DM and group access: disable DMs entirely, allowlist specific senders/groups, or keep open. - dm_policy: open (default) | allowlist | disabled - group_policy: open (default) | allowlist | disabled - Config bridging for YAML → env vars - 22 tests covering all policy combinations Backward compatible — defaults preserve existing behavior. Cherry-picked from PR #11597 by @MassiveMassimo. Dropped the run.py group auth bypass (would have skipped user auth for ALL platforms, not just WhatsApp). --- gateway/config.py | 22 +++ gateway/platforms/whatsapp.py | 47 +++++- tests/gateway/test_whatsapp_group_gating.py | 162 +++++++++++++++++++- 3 files changed, 227 insertions(+), 4 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 2d7407323..7e95a87a8 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -576,6 +576,14 @@ def load_gateway_config() -> GatewayConfig: bridged["free_response_channels"] = platform_cfg["free_response_channels"] if "mention_patterns" in platform_cfg: bridged["mention_patterns"] = platform_cfg["mention_patterns"] + if "dm_policy" in platform_cfg: + bridged["dm_policy"] = platform_cfg["dm_policy"] + if "allow_from" in platform_cfg: + bridged["allow_from"] = platform_cfg["allow_from"] + if "group_policy" in platform_cfg: + bridged["group_policy"] = platform_cfg["group_policy"] + if "group_allow_from" in platform_cfg: + bridged["group_allow_from"] = platform_cfg["group_allow_from"] if plat == Platform.DISCORD and "channel_skill_bindings" in platform_cfg: bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"] if "channel_prompts" in platform_cfg: @@ -700,6 +708,20 @@ def load_gateway_config() -> GatewayConfig: if isinstance(frc, list): frc = ",".join(str(v) for v in frc) os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc) + if "dm_policy" in whatsapp_cfg and not os.getenv("WHATSAPP_DM_POLICY"): + os.environ["WHATSAPP_DM_POLICY"] = str(whatsapp_cfg["dm_policy"]).lower() + af = whatsapp_cfg.get("allow_from") + if af is not None and not os.getenv("WHATSAPP_ALLOWED_USERS"): + if isinstance(af, list): + af = ",".join(str(v) for v in af) + os.environ["WHATSAPP_ALLOWED_USERS"] = str(af) + if "group_policy" in whatsapp_cfg and not os.getenv("WHATSAPP_GROUP_POLICY"): + os.environ["WHATSAPP_GROUP_POLICY"] = str(whatsapp_cfg["group_policy"]).lower() + gaf = whatsapp_cfg.get("group_allow_from") + if gaf is not None and not os.getenv("WHATSAPP_GROUP_ALLOWED_USERS"): + if isinstance(gaf, list): + gaf = ",".join(str(v) for v in gaf) + os.environ["WHATSAPP_GROUP_ALLOWED_USERS"] = str(gaf) # DingTalk settings → env vars (env vars take precedence) dingtalk_cfg = yaml_cfg.get("dingtalk", {}) diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index b998da345..e1ccd2234 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -118,6 +118,10 @@ class WhatsAppAdapter(BasePlatformAdapter): - bridge_script: Path to the Node.js bridge script - bridge_port: Port for HTTP communication (default: 3000) - session_path: Path to store WhatsApp session data + - dm_policy: "open" | "allowlist" | "disabled" — how DMs are handled (default: "open") + - allow_from: List of sender IDs allowed in DMs (when dm_policy="allowlist") + - group_policy: "open" | "allowlist" | "disabled" — which groups are processed (default: "open") + - group_allow_from: List of group JIDs allowed (when group_policy="allowlist") """ # WhatsApp message limits — practical UX limit, not protocol max. @@ -140,6 +144,10 @@ class WhatsAppAdapter(BasePlatformAdapter): get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") )) self._reply_prefix: Optional[str] = config.extra.get("reply_prefix") + self._dm_policy = str(config.extra.get("dm_policy") or os.getenv("WHATSAPP_DM_POLICY", "open")).strip().lower() + self._allow_from = self._coerce_allow_list(config.extra.get("allow_from") or config.extra.get("allowFrom")) + self._group_policy = str(config.extra.get("group_policy") or os.getenv("WHATSAPP_GROUP_POLICY", "open")).strip().lower() + self._group_allow_from = self._coerce_allow_list(config.extra.get("group_allow_from") or config.extra.get("groupAllowFrom")) self._mention_patterns = self._compile_mention_patterns() self._message_queue: asyncio.Queue = asyncio.Queue() self._bridge_log_fh = None @@ -163,6 +171,33 @@ 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 _coerce_allow_list(raw) -> set[str]: + """Parse allow_from / group_allow_from from config or env var.""" + if raw is None: + return set() + 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 _is_dm_allowed(self, sender_id: str) -> bool: + """Check whether a DM from the given sender should be processed.""" + if self._dm_policy == "disabled": + return False + if self._dm_policy == "allowlist": + return sender_id in self._allow_from + # "open" — all DMs allowed + return True + + def _is_group_allowed(self, chat_id: str) -> bool: + """Check whether a group chat should be processed.""" + if self._group_policy == "disabled": + return False + if self._group_policy == "allowlist": + return chat_id in self._group_allow_from + # "open" — all groups allowed + return True + def _compile_mention_patterns(self): patterns = self.config.extra.get("mention_patterns") if patterns is None: @@ -255,8 +290,18 @@ class WhatsAppAdapter(BasePlatformAdapter): return cleaned.strip() or text def _should_process_message(self, data: Dict[str, Any]) -> bool: - if not data.get("isGroup"): + is_group = data.get("isGroup", False) + if is_group: + chat_id = str(data.get("chatId") or "") + if not self._is_group_allowed(chat_id): + return False + else: + sender_id = str(data.get("senderId") or data.get("from") or "") + if not self._is_dm_allowed(sender_id): + return False + # DMs that pass the policy gate are always processed return True + # Group messages: check mention / free-response settings chat_id = str(data.get("chatId") or "") if chat_id in self._whatsapp_free_response_chats(): return True diff --git a/tests/gateway/test_whatsapp_group_gating.py b/tests/gateway/test_whatsapp_group_gating.py index 87caa46ba..afe974320 100644 --- a/tests/gateway/test_whatsapp_group_gating.py +++ b/tests/gateway/test_whatsapp_group_gating.py @@ -4,7 +4,8 @@ from unittest.mock import AsyncMock from gateway.config import Platform, PlatformConfig, load_gateway_config -def _make_adapter(require_mention=None, mention_patterns=None, free_response_chats=None): +def _make_adapter(require_mention=None, mention_patterns=None, free_response_chats=None, + dm_policy=None, allow_from=None, group_policy=None, group_allow_from=None): from gateway.platforms.whatsapp import WhatsAppAdapter extra = {} @@ -14,12 +15,25 @@ def _make_adapter(require_mention=None, mention_patterns=None, free_response_cha extra["mention_patterns"] = mention_patterns if free_response_chats is not None: extra["free_response_chats"] = free_response_chats + if dm_policy is not None: + extra["dm_policy"] = dm_policy + if allow_from is not None: + extra["allow_from"] = allow_from + if group_policy is not None: + extra["group_policy"] = group_policy + if group_allow_from is not None: + extra["group_allow_from"] = group_allow_from adapter = object.__new__(WhatsAppAdapter) adapter.platform = Platform.WHATSAPP adapter.config = PlatformConfig(enabled=True, extra=extra) adapter._message_handler = AsyncMock() + adapter._dm_policy = str(extra.get("dm_policy", "open")).strip().lower() + adapter._allow_from = WhatsAppAdapter._coerce_allow_list(extra.get("allow_from")) + adapter._group_policy = str(extra.get("group_policy", "open")).strip().lower() + adapter._group_allow_from = WhatsAppAdapter._coerce_allow_list(extra.get("group_allow_from")) adapter._mention_patterns = adapter._compile_mention_patterns() + adapter._free_response_chats = adapter._whatsapp_free_response_chats() return adapter @@ -36,6 +50,21 @@ def _group_message(body="hello", **overrides): return data +def _dm_message(body="hello", **overrides): + data = { + "isGroup": False, + "body": body, + "senderId": "6281234567890@s.whatsapp.net", + "from": "6281234567890@s.whatsapp.net", + "botIds": [], + "mentionedIds": [], + } + data.update(overrides) + return data + + +# --- Existing tests (unchanged logic, updated helper) --- + def test_group_messages_can_be_opened_via_config(): adapter = _make_adapter(require_mention=False) @@ -118,10 +147,10 @@ def test_free_response_chats_does_not_bypass_other_groups(): assert adapter._should_process_message(_group_message("hello everyone")) is False -def test_dm_always_passes_even_with_require_mention(): +def test_dm_passes_with_default_open_policy(): adapter = _make_adapter(require_mention=True) - dm = {"isGroup": False, "body": "hello", "botIds": [], "mentionedIds": []} + dm = _dm_message("hello") assert adapter._should_process_message(dm) is True @@ -140,3 +169,130 @@ def test_mention_stripping_preserves_body_when_no_mention(): data = _group_message("just a normal message") cleaned = adapter._clean_bot_mention_text(data["body"], data) assert cleaned == "just a normal message" + + +# --- New dm_policy tests --- + +def test_dm_policy_disabled_blocks_all_dms(): + adapter = _make_adapter(dm_policy="disabled") + + assert adapter._should_process_message(_dm_message("hello")) is False + + +def test_dm_policy_disabled_still_allows_groups(): + adapter = _make_adapter(dm_policy="disabled", require_mention=False) + + assert adapter._should_process_message(_group_message("hello")) is True + + +def test_dm_policy_allowlist_blocks_unlisted_sender(): + adapter = _make_adapter(dm_policy="allowlist", allow_from=["6289999999999@s.whatsapp.net"]) + + assert adapter._should_process_message(_dm_message("hello")) is False + + +def test_dm_policy_allowlist_allows_listed_sender(): + adapter = _make_adapter(dm_policy="allowlist", allow_from=["6281234567890@s.whatsapp.net"]) + + assert adapter._should_process_message(_dm_message("hello")) is True + + +def test_dm_policy_open_allows_all_dms(): + adapter = _make_adapter(dm_policy="open") + + assert adapter._should_process_message(_dm_message("hello")) is True + + +# --- New group_policy tests --- + +def test_group_policy_disabled_blocks_all_groups(): + adapter = _make_adapter(group_policy="disabled", require_mention=False) + + assert adapter._should_process_message(_group_message("hello")) is False + + +def test_group_policy_disabled_still_allows_dms(): + adapter = _make_adapter(group_policy="disabled") + + assert adapter._should_process_message(_dm_message("hello")) is True + + +def test_group_policy_allowlist_blocks_unlisted_group(): + adapter = _make_adapter(group_policy="allowlist", group_allow_from=["999999999999@g.us"]) + + assert adapter._should_process_message(_group_message("agus test")) is False + + +def test_group_policy_allowlist_allows_listed_group(): + adapter = _make_adapter( + group_policy="allowlist", + group_allow_from=["120363001234567890@g.us"], + require_mention=True, + mention_patterns=[r"^\s*(?:(?:@)?(?:agus|Augustus))\b"], + ) + + # Listed group — passes the allowlist gate, mention still required + assert adapter._should_process_message(_group_message("hello")) is False + assert adapter._should_process_message(_group_message("agus test")) is True + + +def test_group_policy_open_allows_all_groups(): + adapter = _make_adapter(group_policy="open", require_mention=True) + + # Open policy — all groups pass the gate (mention still needed) + assert adapter._should_process_message(_group_message("hello")) is False + assert adapter._should_process_message(_group_message("/status")) is True + + +# --- Config bridging tests --- + +def test_config_bridges_whatsapp_dm_and_group_policy(monkeypatch, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "whatsapp:\n" + " dm_policy: disabled\n" + " group_policy: allowlist\n" + " group_allow_from:\n" + " - \"120363001234567890@g.us\"\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("WHATSAPP_DM_POLICY", raising=False) + monkeypatch.delenv("WHATSAPP_GROUP_POLICY", raising=False) + monkeypatch.delenv("WHATSAPP_GROUP_ALLOWED_USERS", raising=False) + + config = load_gateway_config() + + assert config is not None + assert config.platforms[Platform.WHATSAPP].extra["dm_policy"] == "disabled" + assert config.platforms[Platform.WHATSAPP].extra["group_policy"] == "allowlist" + assert config.platforms[Platform.WHATSAPP].extra["group_allow_from"] == ["120363001234567890@g.us"] + assert __import__("os").environ["WHATSAPP_DM_POLICY"] == "disabled" + assert __import__("os").environ["WHATSAPP_GROUP_POLICY"] == "allowlist" + assert __import__("os").environ["WHATSAPP_GROUP_ALLOWED_USERS"] == "120363001234567890@g.us" + + +def test_config_bridges_whatsapp_allow_from(monkeypatch, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "whatsapp:\n" + " dm_policy: allowlist\n" + " allow_from:\n" + " - \"6281234567890@s.whatsapp.net\"\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("WHATSAPP_DM_POLICY", raising=False) + monkeypatch.delenv("WHATSAPP_ALLOWED_USERS", raising=False) + + config = load_gateway_config() + + assert config is not None + assert config.platforms[Platform.WHATSAPP].extra["dm_policy"] == "allowlist" + 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"