NousResearch/hermes-agent

WhatsApp group and DM interaction policy controls

Cluster 7992 · gateway. Data as of

1 open item. Fix merged in #13151 on 20 Apr 2026. Check it covers your case.

The short answer

1open issue
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #13151. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #13151. That is separate from the fix event.
Still open
1 issue and 0 PRs.

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.

Open issues1

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

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #13151 feat(whatsapp): add dm_policy and group_policy parity with WeCom/Weixin/QQ adapters

    merged to main

    files: gateway/config.py, gateway/platforms/whatsapp.py, scripts/release.py, tests/gateway/test_whatsapp_group_gating.py

    Show the diff
    • 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", {})
    • 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 @@ 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
      @@ -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:
      @@ -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
      @@ -46,6 +46,8 @@
           # contributors (from noreply pattern)
           "[redacted:email]": "snreynolds",
           "[email protected]": "0xbyt4",
      +    "[email protected]": "MassiveMassimo",
      +    "[email protected]": "MassiveMassimo",
           "[email protected]": "kshitijk4poor",
           "[email protected]": "kshitijk4poor",
           "[redacted:email]": "kshitijk4poor",
    • tests/gateway/test_whatsapp_group_gating.py
      @@ -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 = {}
      @@ -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": "[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)
       
      @@ -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=["[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]"

1 PR closed without merging

From the close comments: merged via another change 1

  • #11597 feat(whatsapp): add dm_policy and group_policy parity with WeCom/Weixin/QQ adapters · merged via another change → #13151
Closed issues · 1
  • Closed#7992[Feature]: WhatsApp: Telegram-like group behavior controls (ignore_groups, mention-only replies, and group allowlists)
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#13151
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
20 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.