# NousResearch/hermes-agent · WhatsApp group and DM interaction policy controls

## Open now

- 1 issue, 0 PRs open on GitHub.
- #41989: \[Feature\]: WhatsApp DM-only mode + group interaction controls

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.

## TL;DR

- 4 member(s): 1 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #13151. Verify it addresses your case.
- **Recommended:** read PR #13151 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-13151`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.39`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #41989: \[Feature\]: WhatsApp DM-only mode + group interaction controls

## Closed / duplicate issues

- #7992: \[Feature\]: WhatsApp: Telegram-like group behavior controls (ignore_groups, mention-only replies, and group allowlists)

## Prior & competing PRs

- **#13151: feat(whatsapp): add dm_policy and group_policy parity with WeCom/Weixin/QQ adapters**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/config.py`, `gateway/platforms/whatsapp.py`, `scripts/release.py`, `tests/gateway/test_whatsapp_group_gating.py`
  - fix hunks:
  - `gateway/config.py`

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

    ```diff
    @@ -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", {})
    ```
  - `gateway/platforms/whatsapp.py`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -140,6 +144,10 @@ def __init__(self, config: PlatformConfig):
                 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
    ```

    ```diff
    @@ -163,6 +171,33 @@ def _whatsapp_free_response_chats(self) -> set[str]:
                 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:
    ```

    ```diff
    @@ -255,8 +290,18 @@ def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str:
             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
    ```
  - `scripts/release.py`

    ```diff
    @@ -46,6 +46,8 @@
         # contributors (from noreply pattern)
         "[redacted:email]": "snreynolds",
         "35742124+0xbyt4@users.noreply.github.com": "0xbyt4",
    +    "71184274+MassiveMassimo@users.noreply.github.com": "MassiveMassimo",
    +    "massivemassimo@users.noreply.github.com": "MassiveMassimo",
         "82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
         "kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
         "[redacted:email]": "kshitijk4poor",
    ```
  - `tests/gateway/test_whatsapp_group_gating.py`

    ```diff
    @@ -4,7 +4,8 @@
     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 = {}
    ```

    ```diff
    @@ -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
     
     
    ```

    ```diff
    @@ -36,6 +50,21 @@ def _group_message(body="hello", **overrides):
         return data
     
     
    +def _dm_message(body="hello", **overrides):
    +    data = {
    +        "isGroup": False,
    +        "body": body,
    +        "senderId": "[redacted:email]",
    +        "from": "[redacted:email]",
    +        "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)
     
    ```

    ```diff
    @@ -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
     
     
    ```

    ```diff
    @@ -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=["[redacted:email]"])
    +
    +    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=["[redacted:email]"])
    +
    +    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=["[redacted:email]"])
    +
    +    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=["[redacted:email]"],
    +        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"
    +        "    - \"[redacted:email]\"\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"] == ["[redacted:email]"]
    +    assert __import__("os").environ["WHATSAPP_DM_POLICY"] == "disabled"
    +    assert __import__("os").environ["WHATSAPP_GROUP_POLICY"] == "allowlist"
    +    assert __import__("os").environ["WHATSAPP_GROUP_ALLOWED_USERS"] == "[redacted:email]"
    +
    +
    +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"
    +        "    - \"[redacted:email]\"\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"] == ["[redacted:email]"]
    +    assert __import__("os").environ["WHATSAPP_DM_POLICY"] == "allowlist"
    +    assert __import__("os").environ["WHATSAPP_ALLOWED_USERS"] == "[redacted:email]"
    ```
- **#11597: feat(whatsapp): add dm_policy and group_policy parity with WeCom/Weixin/QQ adapters**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#13151`

## Fix status

- Merged to main in PR #13151 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-20`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
