mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(whatsapp): add dm_policy and group_policy parity with WeCom/Weixin/QQ adapters
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).
This commit is contained in:
parent
ff56bebdf3
commit
7972ff2a2c
3 changed files with 227 additions and 4 deletions
|
|
@ -576,6 +576,14 @@ def load_gateway_config() -> GatewayConfig:
|
||||||
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
|
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
|
||||||
if "mention_patterns" in platform_cfg:
|
if "mention_patterns" in platform_cfg:
|
||||||
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
|
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:
|
if plat == Platform.DISCORD and "channel_skill_bindings" in platform_cfg:
|
||||||
bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"]
|
bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"]
|
||||||
if "channel_prompts" in platform_cfg:
|
if "channel_prompts" in platform_cfg:
|
||||||
|
|
@ -700,6 +708,20 @@ def load_gateway_config() -> GatewayConfig:
|
||||||
if isinstance(frc, list):
|
if isinstance(frc, list):
|
||||||
frc = ",".join(str(v) for v in frc)
|
frc = ",".join(str(v) for v in frc)
|
||||||
os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(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 settings → env vars (env vars take precedence)
|
||||||
dingtalk_cfg = yaml_cfg.get("dingtalk", {})
|
dingtalk_cfg = yaml_cfg.get("dingtalk", {})
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,10 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||||
- bridge_script: Path to the Node.js bridge script
|
- bridge_script: Path to the Node.js bridge script
|
||||||
- bridge_port: Port for HTTP communication (default: 3000)
|
- bridge_port: Port for HTTP communication (default: 3000)
|
||||||
- session_path: Path to store WhatsApp session data
|
- 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.
|
# WhatsApp message limits — practical UX limit, not protocol max.
|
||||||
|
|
@ -140,6 +144,10 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||||
get_hermes_dir("platforms/whatsapp/session", "whatsapp/session")
|
get_hermes_dir("platforms/whatsapp/session", "whatsapp/session")
|
||||||
))
|
))
|
||||||
self._reply_prefix: Optional[str] = config.extra.get("reply_prefix")
|
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._mention_patterns = self._compile_mention_patterns()
|
||||||
self._message_queue: asyncio.Queue = asyncio.Queue()
|
self._message_queue: asyncio.Queue = asyncio.Queue()
|
||||||
self._bridge_log_fh = None
|
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 {str(part).strip() for part in raw if str(part).strip()}
|
||||||
return {part.strip() for part in str(raw).split(",") if 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):
|
def _compile_mention_patterns(self):
|
||||||
patterns = self.config.extra.get("mention_patterns")
|
patterns = self.config.extra.get("mention_patterns")
|
||||||
if patterns is None:
|
if patterns is None:
|
||||||
|
|
@ -255,8 +290,18 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||||
return cleaned.strip() or text
|
return cleaned.strip() or text
|
||||||
|
|
||||||
def _should_process_message(self, data: Dict[str, Any]) -> bool:
|
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
|
return True
|
||||||
|
# Group messages: check mention / free-response settings
|
||||||
chat_id = str(data.get("chatId") or "")
|
chat_id = str(data.get("chatId") or "")
|
||||||
if chat_id in self._whatsapp_free_response_chats():
|
if chat_id in self._whatsapp_free_response_chats():
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ from unittest.mock import AsyncMock
|
||||||
from gateway.config import Platform, PlatformConfig, load_gateway_config
|
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
|
from gateway.platforms.whatsapp import WhatsAppAdapter
|
||||||
|
|
||||||
extra = {}
|
extra = {}
|
||||||
|
|
@ -14,12 +15,25 @@ def _make_adapter(require_mention=None, mention_patterns=None, free_response_cha
|
||||||
extra["mention_patterns"] = mention_patterns
|
extra["mention_patterns"] = mention_patterns
|
||||||
if free_response_chats is not None:
|
if free_response_chats is not None:
|
||||||
extra["free_response_chats"] = free_response_chats
|
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 = object.__new__(WhatsAppAdapter)
|
||||||
adapter.platform = Platform.WHATSAPP
|
adapter.platform = Platform.WHATSAPP
|
||||||
adapter.config = PlatformConfig(enabled=True, extra=extra)
|
adapter.config = PlatformConfig(enabled=True, extra=extra)
|
||||||
adapter._message_handler = AsyncMock()
|
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._mention_patterns = adapter._compile_mention_patterns()
|
||||||
|
adapter._free_response_chats = adapter._whatsapp_free_response_chats()
|
||||||
return adapter
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -36,6 +50,21 @@ def _group_message(body="hello", **overrides):
|
||||||
return data
|
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():
|
def test_group_messages_can_be_opened_via_config():
|
||||||
adapter = _make_adapter(require_mention=False)
|
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
|
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)
|
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
|
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")
|
data = _group_message("just a normal message")
|
||||||
cleaned = adapter._clean_bot_mention_text(data["body"], data)
|
cleaned = adapter._clean_bot_mention_text(data["body"], data)
|
||||||
assert cleaned == "just a normal message"
|
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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue