diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index a88c7e52b9374..56378ce24aa32 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -275,6 +275,7 @@ class FeishuAdapterSettings: ws_reconnect_interval: int = 120 ws_ping_interval: Optional[int] = None ws_ping_timeout: Optional[int] = None + require_mention: bool = True admins: frozenset[str] = frozenset() default_group_policy: str = "" group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict) @@ -309,6 +310,21 @@ def _to_boolean(value: Any) -> bool: return value is True or value == 1 or value == "true" +def _feishu_require_mention(extra: Dict[str, Any]) -> bool: + """Parse FEISHU_REQUIRE_MENTION using explicit-false semantics. + + The safe default is True (mention gating on). Only ``false``, ``0``, + ``no``, or ``off`` disable it — matching the pattern used by Slack, + Discord, and Matrix adapters. + """ + configured = extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.strip().lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("FEISHU_REQUIRE_MENTION", "true").strip().lower() not in ("false", "0", "no", "off") + + def _is_style_enabled(style: Dict[str, Any] | None, key: str) -> bool: if not style: return False @@ -1140,6 +1156,7 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: ws_ping_interval=_coerce_int(extra.get("ws_ping_interval"), default=None, min_value=1), ws_ping_timeout=_coerce_int(extra.get("ws_ping_timeout"), default=None, min_value=1), admins=admins, + require_mention=_feishu_require_mention(extra), default_group_policy=default_group_policy, group_rules=group_rules, ) @@ -1154,6 +1171,7 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None: self._group_policy = settings.group_policy self._allowed_group_users = set(settings.allowed_group_users) self._admins = set(settings.admins) + self._require_mention = settings.require_mention self._default_group_policy = settings.default_group_policy or settings.group_policy self._group_rules = settings.group_rules self._bot_open_id = settings.bot_open_id @@ -3021,9 +3039,16 @@ def _allow_group_message(self, sender_id: Any, chat_id: str = "") -> bool: 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: - """Require an explicit @mention before group messages enter the agent.""" + """Gate group messages: policy check first, then optional mention check. + + When ``require_mention`` is False (controlled via ``FEISHU_REQUIRE_MENTION`` + env var or config ``extra[\"require_mention\"]``), the mention gate is + skipped entirely after the policy gate passes. + """ if not self._allow_group_message(sender_id, chat_id): return False + if not self._require_mention: + return True # @_all is Feishu's @everyone placeholder — always route to the bot. raw_content = getattr(message, "content", "") or "" if "@_all" in raw_content: diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 47f274d1b7e43..eabb24d539d60 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -2922,3 +2922,178 @@ async def _direct(func, *args, **kwargs): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_broken")) self.assertIsNone(result) + + +class TestRequireMention(unittest.TestCase): + """Tests for the FEISHU_REQUIRE_MENTION configuration (closes #5465).""" + + # ── Parsing: _feishu_require_mention helper ────────────────────────── + + def test_parse_require_mention_defaults_to_true(self): + from gateway.platforms.feishu import _feishu_require_mention + + with patch.dict(os.environ, {}, clear=True): + self.assertTrue(_feishu_require_mention({})) + + def test_parse_require_mention_explicit_true(self): + from gateway.platforms.feishu import _feishu_require_mention + + with patch.dict(os.environ, {"FEISHU_REQUIRE_MENTION": "true"}, clear=True): + self.assertTrue(_feishu_require_mention({})) + + def test_parse_require_mention_false(self): + from gateway.platforms.feishu import _feishu_require_mention + + with patch.dict(os.environ, {"FEISHU_REQUIRE_MENTION": "false"}, clear=True): + self.assertFalse(_feishu_require_mention({})) + + def test_parse_require_mention_zero(self): + from gateway.platforms.feishu import _feishu_require_mention + + with patch.dict(os.environ, {"FEISHU_REQUIRE_MENTION": "0"}, clear=True): + self.assertFalse(_feishu_require_mention({})) + + def test_parse_require_mention_no(self): + from gateway.platforms.feishu import _feishu_require_mention + + with patch.dict(os.environ, {"FEISHU_REQUIRE_MENTION": "no"}, clear=True): + self.assertFalse(_feishu_require_mention({})) + + def test_parse_require_mention_off(self): + from gateway.platforms.feishu import _feishu_require_mention + + with patch.dict(os.environ, {"FEISHU_REQUIRE_MENTION": "off"}, clear=True): + self.assertFalse(_feishu_require_mention({})) + + def test_parse_require_mention_random_string_stays_true(self): + from gateway.platforms.feishu import _feishu_require_mention + + with patch.dict(os.environ, {"FEISHU_REQUIRE_MENTION": "yes"}, clear=True): + self.assertTrue(_feishu_require_mention({})) + + def test_parse_require_mention_empty_string_stays_true(self): + from gateway.platforms.feishu import _feishu_require_mention + + with patch.dict(os.environ, {"FEISHU_REQUIRE_MENTION": ""}, clear=True): + self.assertTrue(_feishu_require_mention({})) + + def test_parse_require_mention_config_extra_overrides_env(self): + from gateway.platforms.feishu import _feishu_require_mention + + with patch.dict(os.environ, {"FEISHU_REQUIRE_MENTION": "true"}, clear=True): + self.assertFalse(_feishu_require_mention({"require_mention": "false"})) + + def test_parse_require_mention_config_extra_bool_true(self): + from gateway.platforms.feishu import _feishu_require_mention + + with patch.dict(os.environ, {}, clear=True): + self.assertTrue(_feishu_require_mention({"require_mention": True})) + + def test_parse_require_mention_config_extra_bool_false(self): + from gateway.platforms.feishu import _feishu_require_mention + + with patch.dict(os.environ, {"FEISHU_REQUIRE_MENTION": "true"}, clear=True): + self.assertFalse(_feishu_require_mention({"require_mention": False})) + + # ── Adapter integration ────────────────────────────────────────────── + + @patch.dict( + os.environ, + {"FEISHU_GROUP_POLICY": "open", "FEISHU_REQUIRE_MENTION": "false"}, + clear=True, + ) + def test_group_message_accepted_without_mention_when_require_mention_false(self): + """When require_mention=false, group messages bypass the mention gate.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + message = SimpleNamespace(mentions=[], content='{"text":"hello"}', message_type="text") + sender_id = SimpleNamespace(open_id="ou_any", user_id=None) + + self.assertTrue(adapter._should_accept_group_message(message, sender_id, "")) + + @patch.dict( + os.environ, + {"FEISHU_GROUP_POLICY": "open", "FEISHU_REQUIRE_MENTION": "false"}, + clear=True, + ) + def test_group_message_still_requires_policy_gate_when_require_mention_false(self): + """Policy gate still applies even when mention gate is disabled.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + config = PlatformConfig( + extra={ + "group_rules": { + "oc_chat_a": {"policy": "disabled"}, + }, + "require_mention": False, + } + ) + adapter = FeishuAdapter(config) + message = SimpleNamespace(mentions=[], content='{"text":"hello"}', message_type="text") + sender_id = SimpleNamespace(open_id="ou_any", user_id=None) + + self.assertFalse(adapter._should_accept_group_message(message, sender_id, "oc_chat_a")) + + @patch.dict( + os.environ, + {"FEISHU_GROUP_POLICY": "allowlist", "FEISHU_ALLOWED_USERS": "ou_allowed", "FEISHU_REQUIRE_MENTION": "false"}, + clear=True, + ) + def test_allowlist_policy_still_applies_without_mention(self): + """With require_mention=false + allowlist policy, only allowed users pass.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + message = SimpleNamespace(mentions=[], content='{"text":"hello"}', message_type="text") + + self.assertTrue( + adapter._should_accept_group_message( + message, + SimpleNamespace(open_id="ou_allowed", user_id=None), + "", + ) + ) + self.assertFalse( + adapter._should_accept_group_message( + message, + SimpleNamespace(open_id="ou_blocked", user_id=None), + "", + ) + ) + + @patch.dict( + os.environ, + {"FEISHU_GROUP_POLICY": "open"}, + clear=True, + ) + def test_default_require_mention_still_enforces_mention(self): + """Backward compat: when FEISHU_REQUIRE_MENTION is not set, mention is required.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + message = SimpleNamespace(mentions=[], content='{"text":"hello"}', message_type="text") + sender_id = SimpleNamespace(open_id="ou_any", user_id=None) + + self.assertFalse(adapter._should_accept_group_message(message, sender_id, "")) + + @patch.dict( + os.environ, + {"FEISHU_GROUP_POLICY": "open", "FEISHU_REQUIRE_MENTION": "false"}, + clear=True, + ) + def test_require_mention_false_via_config_extra(self): + """require_mention=false can also be set via PlatformConfig.extra.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + config = PlatformConfig(extra={"require_mention": "false"}) + adapter = FeishuAdapter(config) + message = SimpleNamespace(mentions=[], content='{"text":"hello"}', message_type="text") + sender_id = SimpleNamespace(open_id="ou_any", user_id=None) + + self.assertTrue(adapter._should_accept_group_message(message, sender_id, ""))