mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(feishu): add require_mention and free_response_chats config for group messages
Align Feishu platform with other messaging platforms (Telegram, Discord, WhatsApp) by introducing a configurable mention requirement for group messages. - Add 'require_mention' config (default: true) to control whether group messages must @mention the bot to trigger processing. - Add 'free_response_chats' config to whitelist specific group chats that bypass both mention and user allowlist checks. - Support both YAML config (feishu.require_mention, feishu.free_response_chats) and env vars (FEISHU_REQUIRE_MENTION, FEISHU_FREE_RESPONSE_CHATS). - Update gateway config loader to map free_response_chats from YAML to platform extra dict. - Update tests to cover new mention bypass and free-response chat scenarios.
This commit is contained in:
parent
4350668ae4
commit
7eb2761b54
4 changed files with 57 additions and 18 deletions
|
|
@ -574,6 +574,8 @@ def load_gateway_config() -> GatewayConfig:
|
||||||
bridged["require_mention"] = platform_cfg["require_mention"]
|
bridged["require_mention"] = platform_cfg["require_mention"]
|
||||||
if "free_response_channels" in platform_cfg:
|
if "free_response_channels" in platform_cfg:
|
||||||
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
|
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
|
||||||
|
if "free_response_chats" in platform_cfg:
|
||||||
|
bridged["free_response_chats"] = platform_cfg["free_response_chats"]
|
||||||
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:
|
if "dm_policy" in platform_cfg:
|
||||||
|
|
|
||||||
|
|
@ -387,6 +387,8 @@ class FeishuAdapterSettings:
|
||||||
admins: frozenset[str] = frozenset()
|
admins: frozenset[str] = frozenset()
|
||||||
default_group_policy: str = ""
|
default_group_policy: str = ""
|
||||||
group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict)
|
group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict)
|
||||||
|
require_mention: bool = True
|
||||||
|
free_response_chats: frozenset[str] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -1446,6 +1448,15 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||||
admins=admins,
|
admins=admins,
|
||||||
default_group_policy=default_group_policy,
|
default_group_policy=default_group_policy,
|
||||||
group_rules=group_rules,
|
group_rules=group_rules,
|
||||||
|
require_mention=_to_boolean(
|
||||||
|
extra.get("require_mention", os.getenv("FEISHU_REQUIRE_MENTION", "true"))
|
||||||
|
),
|
||||||
|
free_response_chats=frozenset(
|
||||||
|
str(c).strip()
|
||||||
|
for raw in [extra.get("free_response_chats") or os.getenv("FEISHU_FREE_RESPONSE_CHATS", "")]
|
||||||
|
for c in (raw.split(",") if isinstance(raw, str) else raw)
|
||||||
|
if str(c).strip()
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
|
def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
|
||||||
|
|
@ -1460,6 +1471,8 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||||
self._admins = set(settings.admins)
|
self._admins = set(settings.admins)
|
||||||
self._default_group_policy = settings.default_group_policy or settings.group_policy
|
self._default_group_policy = settings.default_group_policy or settings.group_policy
|
||||||
self._group_rules = settings.group_rules
|
self._group_rules = settings.group_rules
|
||||||
|
self._require_mention = settings.require_mention
|
||||||
|
self._free_response_chats = set(settings.free_response_chats)
|
||||||
self._bot_open_id = settings.bot_open_id
|
self._bot_open_id = settings.bot_open_id
|
||||||
self._bot_user_id = settings.bot_user_id
|
self._bot_user_id = settings.bot_user_id
|
||||||
self._bot_name = settings.bot_name
|
self._bot_name = settings.bot_name
|
||||||
|
|
@ -3626,9 +3639,20 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||||
return bool(sender_ids and (sender_ids & self._allowed_group_users))
|
return bool(sender_ids and (sender_ids & self._allowed_group_users))
|
||||||
|
|
||||||
def _should_accept_group_message(self, message: Any, sender_id: Any, chat_id: str = "") -> bool:
|
def _should_accept_group_message(self, message: Any, sender_id: Any, chat_id: str = "") -> bool:
|
||||||
"""Require an explicit @mention before group messages enter the agent."""
|
"""Decide whether a group message should enter the agent pipeline."""
|
||||||
|
|
||||||
|
# 1. Highest priority: Free-response chats bypass ALL checks (mention & user allowlist)
|
||||||
|
if chat_id and chat_id in self._free_response_chats:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 2. Policy gate (admins, allowlist/blacklist)
|
||||||
if not self._allow_group_message(sender_id, chat_id):
|
if not self._allow_group_message(sender_id, chat_id):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 3. If mention requirement is disabled, accept all allowed messages
|
||||||
|
if not self._require_mention:
|
||||||
|
return True
|
||||||
|
|
||||||
# @_all is Feishu's @everyone placeholder — always route to the bot.
|
# @_all is Feishu's @everyone placeholder — always route to the bot.
|
||||||
raw_content = getattr(message, "content", "") or ""
|
raw_content = getattr(message, "content", "") or ""
|
||||||
if "@_all" in raw_content:
|
if "@_all" in raw_content:
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||||||
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
|
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
|
||||||
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
|
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
|
||||||
|
"FEISHU_REQUIRE_MENTION", "FEISHU_FREE_RESPONSE_CHATS",
|
||||||
"WECOM_BOT_ID", "WECOM_SECRET",
|
"WECOM_BOT_ID", "WECOM_SECRET",
|
||||||
"WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID",
|
"WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID",
|
||||||
"WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY",
|
"WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY",
|
||||||
|
|
|
||||||
|
|
@ -689,32 +689,42 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||||
adapter._on_reaction_event("im.message.reaction.created_v1", data)
|
adapter._on_reaction_event("im.message.reaction.created_v1", data)
|
||||||
run_threadsafe.assert_called_once()
|
run_threadsafe.assert_called_once()
|
||||||
|
|
||||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
|
def test_group_message_accepted_without_mention_when_require_mention_false(self):
|
||||||
def test_group_message_requires_mentions_even_when_policy_open(self):
|
|
||||||
from gateway.config import PlatformConfig
|
from gateway.config import PlatformConfig
|
||||||
from gateway.platforms.feishu import FeishuAdapter
|
from gateway.platforms.feishu import FeishuAdapter
|
||||||
|
|
||||||
adapter = FeishuAdapter(PlatformConfig())
|
adapter = FeishuAdapter(PlatformConfig(extra={"require_mention": False}))
|
||||||
message = SimpleNamespace(mentions=[])
|
adapter._group_policy = "allowlist"
|
||||||
|
adapter._allowed_group_users = {"ou_any"}
|
||||||
sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
|
sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
|
||||||
self.assertFalse(adapter._should_accept_group_message(message, sender_id, ""))
|
|
||||||
|
|
||||||
message_with_mention = SimpleNamespace(mentions=[SimpleNamespace(key="@_user_1")])
|
# No mentions at all — should be accepted when require_mention is False
|
||||||
self.assertFalse(adapter._should_accept_group_message(message_with_mention, sender_id, ""))
|
message_no_mention = SimpleNamespace(mentions=[], content="{}")
|
||||||
|
self.assertTrue(adapter._should_accept_group_message(message_no_mention, sender_id, "oc_chat_1"))
|
||||||
|
|
||||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
|
# With @_all — should also be accepted
|
||||||
def test_group_message_with_other_user_mention_is_rejected_when_bot_identity_unknown(self):
|
message_all = SimpleNamespace(mentions=[], content='{"@_all":1}')
|
||||||
|
self.assertTrue(adapter._should_accept_group_message(message_all, sender_id, "oc_chat_1"))
|
||||||
|
|
||||||
|
def test_group_message_accepted_in_free_response_chats(self):
|
||||||
from gateway.config import PlatformConfig
|
from gateway.config import PlatformConfig
|
||||||
from gateway.platforms.feishu import FeishuAdapter
|
from gateway.platforms.feishu import FeishuAdapter
|
||||||
|
|
||||||
adapter = FeishuAdapter(PlatformConfig())
|
adapter = FeishuAdapter(PlatformConfig(extra={
|
||||||
|
"require_mention": True,
|
||||||
|
"free_response_chats": ["oc_free_chat_1", "oc_free_chat_2"]
|
||||||
|
}))
|
||||||
|
adapter._group_policy = "allowlist"
|
||||||
|
adapter._allowed_group_users = {"ou_any"}
|
||||||
sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
|
sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
|
||||||
other_mention = SimpleNamespace(
|
|
||||||
name="Other User",
|
|
||||||
id=SimpleNamespace(open_id="ou_other", user_id="u_other"),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id, ""))
|
# Chat in free_response_chats — accepted without mention
|
||||||
|
message = SimpleNamespace(mentions=[], content="{}")
|
||||||
|
self.assertTrue(adapter._should_accept_group_message(message, sender_id, "oc_free_chat_1"))
|
||||||
|
self.assertTrue(adapter._should_accept_group_message(message, sender_id, "oc_free_chat_2"))
|
||||||
|
|
||||||
|
# Chat NOT in free_response_chats — rejected without mention
|
||||||
|
self.assertFalse(adapter._should_accept_group_message(message, sender_id, "oc_normal_chat"))
|
||||||
|
|
||||||
@patch.dict(
|
@patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
|
|
@ -1004,13 +1014,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
|
|
||||||
def test_group_message_matches_bot_open_id_when_configured(self):
|
def test_group_message_matches_bot_open_id_when_configured(self):
|
||||||
from gateway.config import PlatformConfig
|
from gateway.config import PlatformConfig
|
||||||
from gateway.platforms.feishu import FeishuAdapter
|
from gateway.platforms.feishu import FeishuAdapter
|
||||||
|
|
||||||
adapter = FeishuAdapter(PlatformConfig())
|
adapter = FeishuAdapter(PlatformConfig())
|
||||||
adapter._bot_open_id = "ou_bot"
|
adapter._bot_open_id = "ou_bot"
|
||||||
|
adapter._group_policy = "allowlist"
|
||||||
|
adapter._allowed_group_users = {"ou_any"}
|
||||||
sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
|
sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
|
||||||
|
|
||||||
bot_mention = SimpleNamespace(
|
bot_mention = SimpleNamespace(
|
||||||
|
|
@ -1025,7 +1036,6 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||||
self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[bot_mention]), sender_id, ""))
|
self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[bot_mention]), sender_id, ""))
|
||||||
self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id, ""))
|
self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id, ""))
|
||||||
|
|
||||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
|
|
||||||
def test_group_message_matches_bot_name_when_only_name_available(self):
|
def test_group_message_matches_bot_name_when_only_name_available(self):
|
||||||
"""Name fallback engages when either side lacks an open_id. When BOTH
|
"""Name fallback engages when either side lacks an open_id. When BOTH
|
||||||
the mention and the bot carry open_ids, IDs are authoritative — a
|
the mention and the bot carry open_ids, IDs are authoritative — a
|
||||||
|
|
@ -1037,6 +1047,8 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||||
# Name fallback is the only available signal for any mention.
|
# Name fallback is the only available signal for any mention.
|
||||||
adapter = FeishuAdapter(PlatformConfig())
|
adapter = FeishuAdapter(PlatformConfig())
|
||||||
adapter._bot_name = "Hermes Bot"
|
adapter._bot_name = "Hermes Bot"
|
||||||
|
adapter._group_policy = "allowlist"
|
||||||
|
adapter._allowed_group_users = {"ou_any"}
|
||||||
sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
|
sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
|
||||||
|
|
||||||
name_only_mention = SimpleNamespace(
|
name_only_mention = SimpleNamespace(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue