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:
Ming 2026-04-24 17:53:21 +08:00
parent 4350668ae4
commit 7eb2761b54
4 changed files with 57 additions and 18 deletions

View file

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

View file

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

View file

@ -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",

View file

@ -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(