{"repo":"NousResearch/hermes-agent","public_id":"7992","display_label":"WhatsApp group and DM interaction policy controls","summary":"These issues and pull requests concern adding configurable policies to the WhatsApp adapter for controlling bot behavior in group chats and direct messages, such as mention-only replies, group allowlists, and DM-only mode, bringing it to parity with other adapters.","subsystem":"gateway","counts":{"members":4,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":13151,"merged_at":"2026-04-20T18:56:19+00:00"},"resolution":{"state":"partially_resolved","by":"pr-13151"},"freshness":{"state":"stale","last_verified_at":"2026-04-20T18:56:19+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":7992,"pr_number":13151,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-20T18:56:19+00:00"}],"members":[{"kind":"issue","number":7992,"title":"[Feature]: WhatsApp: Telegram-like group behavior controls (ignore_groups, mention-only replies, and group allowlists)","url":null,"state":"closed"},{"kind":"issue","number":41989,"title":"[Feature]: WhatsApp DM-only mode + group interaction controls","url":null,"state":"open"},{"kind":"pr","number":11597,"title":"feat(whatsapp): add dm_policy and group_policy parity with WeCom/Weixin/QQ adapters","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#13151","number":13151}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":13151,"title":"feat(whatsapp): add dm_policy and group_policy parity with WeCom/Weixin/QQ adapters","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/whatsapp.py","scripts/release.py","tests/gateway/test_whatsapp_group_gating.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -576,6 +576,14 @@ def load_gateway_config() -> GatewayConfig:\n                     bridged[\"free_response_channels\"] = platform_cfg[\"free_response_channels\"]\n                 if \"mention_patterns\" in platform_cfg:\n                     bridged[\"mention_patterns\"] = platform_cfg[\"mention_patterns\"]\n+                if \"dm_policy\" in platform_cfg:\n+                    bridged[\"dm_policy\"] = platform_cfg[\"dm_policy\"]\n+                if \"allow_from\" in platform_cfg:\n+                    bridged[\"allow_from\"] = platform_cfg[\"allow_from\"]\n+                if \"group_policy\" in platform_cfg:\n+                    bridged[\"group_policy\"] = platform_cfg[\"group_policy\"]\n+                if \"group_allow_from\" in platform_cfg:\n+                    bridged[\"group_allow_from\"] = platform_cfg[\"group_allow_from\"]\n                 if plat == Platform.DISCORD and \"channel_skill_bindings\" in platform_cfg:\n                     bridged[\"channel_skill_bindings\"] = platform_cfg[\"channel_skill_bindings\"]\n                 if \"channel_prompts\" in platform_cfg:","@@ -700,6 +708,20 @@ def load_gateway_config() -> GatewayConfig:\n                     if isinstance(frc, list):\n                         frc = \",\".join(str(v) for v in frc)\n                     os.environ[\"WHATSAPP_FREE_RESPONSE_CHATS\"] = str(frc)\n+                if \"dm_policy\" in whatsapp_cfg and not os.getenv(\"WHATSAPP_DM_POLICY\"):\n+                    os.environ[\"WHATSAPP_DM_POLICY\"] = str(whatsapp_cfg[\"dm_policy\"]).lower()\n+                af = whatsapp_cfg.get(\"allow_from\")\n+                if af is not None and not os.getenv(\"WHATSAPP_ALLOWED_USERS\"):\n+                    if isinstance(af, list):\n+                        af = \",\".join(str(v) for v in af)\n+                    os.environ[\"WHATSAPP_ALLOWED_USERS\"] = str(af)\n+                if \"group_policy\" in whatsapp_cfg and not os.getenv(\"WHATSAPP_GROUP_POLICY\"):\n+                    os.environ[\"WHATSAPP_GROUP_POLICY\"] = str(whatsapp_cfg[\"group_policy\"]).lower()\n+                gaf = whatsapp_cfg.get(\"group_allow_from\")\n+                if gaf is not None and not os.getenv(\"WHATSAPP_GROUP_ALLOWED_USERS\"):\n+                    if isinstance(gaf, list):\n+                        gaf = \",\".join(str(v) for v in gaf)\n+                    os.environ[\"WHATSAPP_GROUP_ALLOWED_USERS\"] = str(gaf)\n \n             # DingTalk settings → env vars (env vars take precedence)\n             dingtalk_cfg = yaml_cfg.get(\"dingtalk\", {})"]},{"file":"gateway/platforms/whatsapp.py","hunks":["@@ -118,6 +118,10 @@ class WhatsAppAdapter(BasePlatformAdapter):\n     - bridge_script: Path to the Node.js bridge script\n     - bridge_port: Port for HTTP communication (default: 3000)\n     - session_path: Path to store WhatsApp session data\n+    - dm_policy: \"open\" | \"allowlist\" | \"disabled\" — how DMs are handled (default: \"open\")\n+    - allow_from: List of sender IDs allowed in DMs (when dm_policy=\"allowlist\")\n+    - group_policy: \"open\" | \"allowlist\" | \"disabled\" — which groups are processed (default: \"open\")\n+    - group_allow_from: List of group JIDs allowed (when group_policy=\"allowlist\")\n     \"\"\"\n     \n     # WhatsApp message limits — practical UX limit, not protocol max.","@@ -140,6 +144,10 @@ def __init__(self, config: PlatformConfig):\n             get_hermes_dir(\"platforms/whatsapp/session\", \"whatsapp/session\")\n         ))\n         self._reply_prefix: Optional[str] = config.extra.get(\"reply_prefix\")\n+        self._dm_policy = str(config.extra.get(\"dm_policy\") or os.getenv(\"WHATSAPP_DM_POLICY\", \"open\")).strip().lower()\n+        self._allow_from = self._coerce_allow_list(config.extra.get(\"allow_from\") or config.extra.get(\"allowFrom\"))\n+        self._group_policy = str(config.extra.get(\"group_policy\") or os.getenv(\"WHATSAPP_GROUP_POLICY\", \"open\")).strip().lower()\n+        self._group_allow_from = self._coerce_allow_list(config.extra.get(\"group_allow_from\") or config.extra.get(\"groupAllowFrom\"))\n         self._mention_patterns = self._compile_mention_patterns()\n         self._message_queue: asyncio.Queue = asyncio.Queue()\n         self._bridge_log_fh = None","@@ -163,6 +171,33 @@ def _whatsapp_free_response_chats(self) -> set[str]:\n             return {str(part).strip() for part in raw if str(part).strip()}\n         return {part.strip() for part in str(raw).split(\",\") if part.strip()}\n \n+    @staticmethod\n+    def _coerce_allow_list(raw) -> set[str]:\n+        \"\"\"Parse allow_from / group_allow_from from config or env var.\"\"\"\n+        if raw is None:\n+            return set()\n+        if isinstance(raw, list):\n+            return {str(part).strip() for part in raw if str(part).strip()}\n+        return {part.strip() for part in str(raw).split(\",\") if part.strip()}\n+\n+    def _is_dm_allowed(self, sender_id: str) -> bool:\n+        \"\"\"Check whether a DM from the given sender should be processed.\"\"\"\n+        if self._dm_policy == \"disabled\":\n+            return False\n+        if self._dm_policy == \"allowlist\":\n+            return sender_id in self._allow_from\n+        # \"open\" — all DMs allowed\n+        return True\n+\n+    def _is_group_allowed(self, chat_id: str) -> bool:\n+        \"\"\"Check whether a group chat should be processed.\"\"\"\n+        if self._group_policy == \"disabled\":\n+            return False\n+        if self._group_policy == \"allowlist\":\n+            return chat_id in self._group_allow_from\n+        # \"open\" — all groups allowed\n+        return True\n+\n     def _compile_mention_patterns(self):\n         patterns = self.config.extra.get(\"mention_patterns\")\n         if patterns is None:","@@ -255,8 +290,18 @@ def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str:\n         return cleaned.strip() or text\n \n     def _should_process_message(self, data: Dict[str, Any]) -> bool:\n-        if not data.get(\"isGroup\"):\n+        is_group = data.get(\"isGroup\", False)\n+        if is_group:\n+            chat_id = str(data.get(\"chatId\") or \"\")\n+            if not self._is_group_allowed(chat_id):\n+                return False\n+        else:\n+            sender_id = str(data.get(\"senderId\") or data.get(\"from\") or \"\")\n+            if not self._is_dm_allowed(sender_id):\n+                return False\n+            # DMs that pass the policy gate are always processed\n             return True\n+        # Group messages: check mention / free-response settings\n         chat_id = str(data.get(\"chatId\") or \"\")\n         if chat_id in self._whatsapp_free_response_chats():\n             return True"]},{"file":"scripts/release.py","hunks":["@@ -46,6 +46,8 @@\n     # contributors (from noreply pattern)\n     \"[redacted:email]\": \"snreynolds\",\n     \"35742124+0xbyt4@users.noreply.github.com\": \"0xbyt4\",\n+    \"71184274+MassiveMassimo@users.noreply.github.com\": \"MassiveMassimo\",\n+    \"massivemassimo@users.noreply.github.com\": \"MassiveMassimo\",\n     \"82637225+kshitijk4poor@users.noreply.github.com\": \"kshitijk4poor\",\n     \"kshitijk4poor@users.noreply.github.com\": \"kshitijk4poor\",\n     \"[redacted:email]\": \"kshitijk4poor\","]},{"file":"tests/gateway/test_whatsapp_group_gating.py","hunks":["@@ -4,7 +4,8 @@\n from gateway.config import Platform, PlatformConfig, load_gateway_config\n \n \n-def _make_adapter(require_mention=None, mention_patterns=None, free_response_chats=None):\n+def _make_adapter(require_mention=None, mention_patterns=None, free_response_chats=None,\n+                  dm_policy=None, allow_from=None, group_policy=None, group_allow_from=None):\n     from gateway.platforms.whatsapp import WhatsAppAdapter\n \n     extra = {}","@@ -14,12 +15,25 @@ def _make_adapter(require_mention=None, mention_patterns=None, free_response_cha\n         extra[\"mention_patterns\"] = mention_patterns\n     if free_response_chats is not None:\n         extra[\"free_response_chats\"] = free_response_chats\n+    if dm_policy is not None:\n+        extra[\"dm_policy\"] = dm_policy\n+    if allow_from is not None:\n+        extra[\"allow_from\"] = allow_from\n+    if group_policy is not None:\n+        extra[\"group_policy\"] = group_policy\n+    if group_allow_from is not None:\n+        extra[\"group_allow_from\"] = group_allow_from\n \n     adapter = object.__new__(WhatsAppAdapter)\n     adapter.platform = Platform.WHATSAPP\n     adapter.config = PlatformConfig(enabled=True, extra=extra)\n     adapter._message_handler = AsyncMock()\n+    adapter._dm_policy = str(extra.get(\"dm_policy\", \"open\")).strip().lower()\n+    adapter._allow_from = WhatsAppAdapter._coerce_allow_list(extra.get(\"allow_from\"))\n+    adapter._group_policy = str(extra.get(\"group_policy\", \"open\")).strip().lower()\n+    adapter._group_allow_from = WhatsAppAdapter._coerce_allow_list(extra.get(\"group_allow_from\"))\n     adapter._mention_patterns = adapter._compile_mention_patterns()\n+    adapter._free_response_chats = adapter._whatsapp_free_response_chats()\n     return adapter\n \n ","@@ -36,6 +50,21 @@ def _group_message(body=\"hello\", **overrides):\n     return data\n \n \n+def _dm_message(body=\"hello\", **overrides):\n+    data = {\n+        \"isGroup\": False,\n+        \"body\": body,\n+        \"senderId\": \"[redacted:email]\",\n+        \"from\": \"[redacted:email]\",\n+        \"botIds\": [],\n+        \"mentionedIds\": [],\n+    }\n+    data.update(overrides)\n+    return data\n+\n+\n+# --- Existing tests (unchanged logic, updated helper) ---\n+\n def test_group_messages_can_be_opened_via_config():\n     adapter = _make_adapter(require_mention=False)\n ","@@ -118,10 +147,10 @@ def test_free_response_chats_does_not_bypass_other_groups():\n     assert adapter._should_process_message(_group_message(\"hello everyone\")) is False\n \n \n-def test_dm_always_passes_even_with_require_mention():\n+def test_dm_passes_with_default_open_policy():\n     adapter = _make_adapter(require_mention=True)\n \n-    dm = {\"isGroup\": False, \"body\": \"hello\", \"botIds\": [], \"mentionedIds\": []}\n+    dm = _dm_message(\"hello\")\n     assert adapter._should_process_message(dm) is True\n \n ","@@ -140,3 +169,130 @@ def test_mention_stripping_preserves_body_when_no_mention():\n     data = _group_message(\"just a normal message\")\n     cleaned = adapter._clean_bot_mention_text(data[\"body\"], data)\n     assert cleaned == \"just a normal message\"\n+\n+\n+# --- New dm_policy tests ---\n+\n+def test_dm_policy_disabled_blocks_all_dms():\n+    adapter = _make_adapter(dm_policy=\"disabled\")\n+\n+    assert adapter._should_process_message(_dm_message(\"hello\")) is False\n+\n+\n+def test_dm_policy_disabled_still_allows_groups():\n+    adapter = _make_adapter(dm_policy=\"disabled\", require_mention=False)\n+\n+    assert adapter._should_process_message(_group_message(\"hello\")) is True\n+\n+\n+def test_dm_policy_allowlist_blocks_unlisted_sender():\n+    adapter = _make_adapter(dm_policy=\"allowlist\", allow_from=[\"[redacted:email]\"])\n+\n+    assert adapter._should_process_message(_dm_message(\"hello\")) is False\n+\n+\n+def test_dm_policy_allowlist_allows_listed_sender():\n+    adapter = _make_adapter(dm_policy=\"allowlist\", allow_from=[\"[redacted:email]\"])\n+\n+    assert adapter._should_process_message(_dm_message(\"hello\")) is True\n+\n+\n+def test_dm_policy_open_allows_all_dms():\n+    adapter = _make_adapter(dm_policy=\"open\")\n+\n+    assert adapter._should_process_message(_dm_message(\"hello\")) is True\n+\n+\n+# --- New group_policy tests ---\n+\n+def test_group_policy_disabled_blocks_all_groups():\n+    adapter = _make_adapter(group_policy=\"disabled\", require_mention=False)\n+\n+    assert adapter._should_process_message(_group_message(\"hello\")) is False\n+\n+\n+def test_group_policy_disabled_still_allows_dms():\n+    adapter = _make_adapter(group_policy=\"disabled\")\n+\n+    assert adapter._should_process_message(_dm_message(\"hello\")) is True\n+\n+\n+def test_group_policy_allowlist_blocks_unlisted_group():\n+    adapter = _make_adapter(group_policy=\"allowlist\", group_allow_from=[\"[redacted:email]\"])\n+\n+    assert adapter._should_process_message(_group_message(\"agus test\")) is False\n+\n+\n+def test_group_policy_allowlist_allows_listed_group():\n+    adapter = _make_adapter(\n+        group_policy=\"allowlist\",\n+        group_allow_from=[\"[redacted:email]\"],\n+        require_mention=True,\n+        mention_patterns=[r\"^\\s*(?:(?:@)?(?:agus|Augustus))\\b\"],\n+    )\n+\n+    # Listed group — passes the allowlist gate, mention still required\n+    assert adapter._should_process_message(_group_message(\"hello\")) is False\n+    assert adapter._should_process_message(_group_message(\"agus test\")) is True\n+\n+\n+def test_group_policy_open_allows_all_groups():\n+    adapter = _make_adapter(group_policy=\"open\", require_mention=True)\n+\n+    # Open policy — all groups pass the gate (mention still needed)\n+    assert adapter._should_process_message(_group_message(\"hello\")) is False\n+    assert adapter._should_process_message(_group_message(\"/status\")) is True\n+\n+\n+# --- Config bridging tests ---\n+\n+def test_config_bridges_whatsapp_dm_and_group_policy(monkeypatch, tmp_path):\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    (hermes_home / \"config.yaml\").write_text(\n+        \"whatsapp:\\n\"\n+        \"  dm_policy: disabled\\n\"\n+        \"  group_policy: allowlist\\n\"\n+        \"  group_allow_from:\\n\"\n+        \"    - \\\"[redacted:email]\\\"\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+    monkeypatch.delenv(\"WHATSAPP_DM_POLICY\", raising=False)\n+    monkeypatch.delenv(\"WHATSAPP_GROUP_POLICY\", raising=False)\n+    monkeypatch.delenv(\"WHATSAPP_GROUP_ALLOWED_USERS\", raising=False)\n+\n+    config = load_gateway_config()\n+\n+    assert config is not None\n+    assert config.platforms[Platform.WHATSAPP].extra[\"dm_policy\"] == \"disabled\"\n+    assert config.platforms[Platform.WHATSAPP].extra[\"group_policy\"] == \"allowlist\"\n+    assert config.platforms[Platform.WHATSAPP].extra[\"group_allow_from\"] == [\"[redacted:email]\"]\n+    assert __import__(\"os\").environ[\"WHATSAPP_DM_POLICY\"] == \"disabled\"\n+    assert __import__(\"os\").environ[\"WHATSAPP_GROUP_POLICY\"] == \"allowlist\"\n+    assert __import__(\"os\").environ[\"WHATSAPP_GROUP_ALLOWED_USERS\"] == \"[redacted:email]\"\n+\n+\n+def test_config_bridges_whatsapp_allow_from(monkeypatch, tmp_path):\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    (hermes_home / \"config.yaml\").write_text(\n+        \"whatsapp:\\n\"\n+        \"  dm_policy: allowlist\\n\"\n+        \"  allow_from:\\n\"\n+        \"    - \\\"[redacted:email]\\\"\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+    monkeypatch.delenv(\"WHATSAPP_DM_POLICY\", raising=False)\n+    monkeypatch.delenv(\"WHATSAPP_ALLOWED_USERS\", raising=False)\n+\n+    config = load_gateway_config()\n+\n+    assert config is not None\n+    assert config.platforms[Platform.WHATSAPP].extra[\"dm_policy\"] == \"allowlist\"\n+    assert config.platforms[Platform.WHATSAPP].extra[\"allow_from\"] == [\"[redacted:email]\"]\n+    assert __import__(\"os\").environ[\"WHATSAPP_DM_POLICY\"] == \"allowlist\"\n+    assert __import__(\"os\").environ[\"WHATSAPP_ALLOWED_USERS\"] == \"[redacted:email]\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}