diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 16f5467b2209d..18f89b5b56137 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -278,6 +278,7 @@ class FeishuAdapterSettings: admins: frozenset[str] = frozenset() default_group_policy: str = "" group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict) + require_mention: bool = True # global default; False = no @mention needed in any group @dataclass @@ -287,6 +288,7 @@ class FeishuGroupRule: policy: str # "open" | "allowlist" | "blacklist" | "admin_only" | "disabled" allowlist: set[str] = field(default_factory=set) blacklist: set[str] = field(default_factory=set) + require_mention: Optional[bool] = None # None = inherit global setting @dataclass @@ -1075,10 +1077,15 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: for chat_id, rule_cfg in raw_group_rules.items(): if not isinstance(rule_cfg, dict): continue + raw_rm = rule_cfg.get("require_mention") + per_group_require_mention: Optional[bool] = None + if raw_rm is not None: + per_group_require_mention = _to_boolean(raw_rm) group_rules[str(chat_id)] = FeishuGroupRule( policy=str(rule_cfg.get("policy", "open")).strip().lower(), allowlist=set(str(u).strip() for u in rule_cfg.get("allowlist", []) if str(u).strip()), blacklist=set(str(u).strip() for u in rule_cfg.get("blacklist", []) if str(u).strip()), + require_mention=per_group_require_mention, ) # Bot-level admins @@ -1088,6 +1095,14 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: # Default group policy (for groups not in group_rules) default_group_policy = str(extra.get("default_group_policy", "")).strip().lower() + # Global require_mention: env FEISHU_REQUIRE_MENTION overrides config key + _env_require_mention = os.getenv("FEISHU_REQUIRE_MENTION", "").strip().lower() + if _env_require_mention: + require_mention = _env_require_mention != "false" + else: + raw_cfg_rm = extra.get("require_mention") + require_mention = True if raw_cfg_rm is None else _to_boolean(raw_cfg_rm) + return FeishuAdapterSettings( app_id=str(extra.get("app_id") or os.getenv("FEISHU_APP_ID", "")).strip(), app_secret=str(extra.get("app_secret") or os.getenv("FEISHU_APP_SECRET", "")).strip(), @@ -1144,6 +1159,7 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: admins=admins, default_group_policy=default_group_policy, group_rules=group_rules, + require_mention=require_mention, ) def _apply_settings(self, settings: FeishuAdapterSettings) -> None: @@ -1158,6 +1174,7 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None: self._admins = set(settings.admins) self._default_group_policy = settings.default_group_policy or settings.group_policy self._group_rules = settings.group_rules + self._require_mention = settings.require_mention self._bot_open_id = settings.bot_open_id self._bot_user_id = settings.bot_user_id self._bot_name = settings.bot_name @@ -1720,12 +1737,18 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: data = getattr(response, "data", None) raw_chat_type = str(getattr(data, "chat_type", "") or "").strip().lower() - info = { + raw_member_count = getattr(data, "member_count", None) + info: Dict[str, Any] = { "chat_id": chat_id, "name": str(getattr(data, "name", None) or chat_id), "type": self._map_chat_type(raw_chat_type), "raw_type": raw_chat_type or None, } + if raw_member_count is not None: + try: + info["member_count"] = int(raw_member_count) + except (TypeError, ValueError): + pass self._chat_info_cache[chat_id] = info return dict(info) except Exception: @@ -1772,9 +1795,15 @@ async def _handle_message_event_data(self, data: Any) -> None: chat_type = getattr(message, "chat_type", "p2p") chat_id = getattr(message, "chat_id", "") or "" - if chat_type != "p2p" and not self._should_accept_group_message(message, sender_id, chat_id): - logger.debug("[Feishu] Dropping group message that failed mention/policy gate: %s", message_id) - return + if chat_type != "p2p": + # Pre-fetch chat info (cached) to obtain member_count for auto-detection. + chat_info = await self.get_chat_info(chat_id) + member_count: Optional[int] = chat_info.get("member_count") + if not self._should_accept_group_message( + message, sender_id, chat_id, member_count=member_count + ): + logger.debug("[Feishu] Dropping group message that failed mention/policy gate: %s", message_id) + return await self._process_inbound_message( data=data, message=message, @@ -3022,10 +3051,48 @@ 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.""" + def _should_accept_group_message( + self, + message: Any, + sender_id: Any, + chat_id: str = "", + member_count: Optional[int] = None, + ) -> bool: + """Decide whether a group message should enter the agent. + + Mention requirement priority (highest wins): + 1. Per-group ``require_mention`` in ``group_rules`` config. + 2. Global ``require_mention`` setting / ``FEISHU_REQUIRE_MENTION`` env. + 3. Auto-detection: if the chat has exactly 2 members (one human + bot), + act like a DM and skip the @mention gate. + 4. Default: require @mention. + """ if not self._allow_group_message(sender_id, chat_id): return False + + # --- Resolve effective require_mention --- + rule = self._group_rules.get(chat_id) if chat_id else None + if rule is not None and rule.require_mention is not None: + # Per-group explicit override wins. + effective_require_mention = rule.require_mention + elif not self._require_mention: + # Global flag disabled. + effective_require_mention = False + elif member_count is not None and member_count <= 2: + # Auto-detect: only 1 human + bot → behave like a DM. + logger.debug( + "[Feishu] Skipping @mention gate for small group %s (member_count=%d)", + chat_id, + member_count, + ) + effective_require_mention = False + else: + effective_require_mention = True + + if not effective_require_mention: + return True + + # --- Standard @mention check --- # @_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..bf1f861776d2c 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -2922,3 +2922,248 @@ async def _direct(func, *args, **kwargs): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_broken")) self.assertIsNone(result) + + +class TestRequireMentionConfig(unittest.TestCase): + """Tests for the require_mention config flag (global + per-group).""" + + # ------------------------------------------------------------------ + # Global require_mention=false via env var + # ------------------------------------------------------------------ + + @patch.dict(os.environ, { + "FEISHU_GROUP_POLICY": "open", + "FEISHU_REQUIRE_MENTION": "false", + }, clear=True) + def test_global_require_mention_false_passes_without_at(self): + """Any group message passes when FEISHU_REQUIRE_MENTION=false.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + message = SimpleNamespace( + content='{"text":"hello"}', + mentions=[], + message_type="text", + ) + sender_id = SimpleNamespace(open_id="ou_user", user_id=None) + self.assertTrue(adapter._should_accept_group_message(message, sender_id, "oc_chat")) + + @patch.dict(os.environ, { + "FEISHU_GROUP_POLICY": "open", + "FEISHU_REQUIRE_MENTION": "true", + }, clear=True) + def test_global_require_mention_true_blocks_without_at(self): + """Message without @bot is blocked when FEISHU_REQUIRE_MENTION=true.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + message = SimpleNamespace( + content='{"text":"hello"}', + mentions=[], + message_type="text", + ) + sender_id = SimpleNamespace(open_id="ou_user", user_id=None) + self.assertFalse(adapter._should_accept_group_message(message, sender_id, "oc_chat")) + + # ------------------------------------------------------------------ + # Per-group require_mention override + # ------------------------------------------------------------------ + + @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) + def test_per_group_require_mention_false_overrides_global_true(self): + """Per-group require_mention=false bypasses @mention even when global=true.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter, FeishuGroupRule + + adapter = FeishuAdapter(PlatformConfig()) + # Global default is True; override for this specific group. + adapter._group_rules = { + "oc_target": FeishuGroupRule(policy="open", require_mention=False), + } + message = SimpleNamespace( + content='{"text":"hello"}', + mentions=[], + message_type="text", + ) + sender_id = SimpleNamespace(open_id="ou_user", user_id=None) + # Target group: passes without @mention. + self.assertTrue(adapter._should_accept_group_message(message, sender_id, "oc_target")) + # Other group: still requires @mention. + self.assertFalse(adapter._should_accept_group_message(message, sender_id, "oc_other")) + + @patch.dict(os.environ, { + "FEISHU_GROUP_POLICY": "open", + "FEISHU_REQUIRE_MENTION": "false", + }, clear=True) + def test_per_group_require_mention_true_overrides_global_false(self): + """Per-group require_mention=true re-enables gate even when global=false.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter, FeishuGroupRule + + adapter = FeishuAdapter(PlatformConfig()) + adapter._group_rules = { + "oc_strict": FeishuGroupRule(policy="open", require_mention=True), + } + message = SimpleNamespace( + content='{"text":"hello"}', + mentions=[], + message_type="text", + ) + sender_id = SimpleNamespace(open_id="ou_user", user_id=None) + # Global=false but per-group=true → still blocked. + self.assertFalse(adapter._should_accept_group_message(message, sender_id, "oc_strict")) + # Other group not in rules → falls through to global=false → passes. + self.assertTrue(adapter._should_accept_group_message(message, sender_id, "oc_other")) + + # ------------------------------------------------------------------ + # Auto-detection via member_count + # ------------------------------------------------------------------ + + @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) + def test_member_count_2_skips_mention_gate(self): + """member_count=2 (1 human + bot) auto-bypasses the @mention gate.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + message = SimpleNamespace( + content='{"text":"hello"}', + mentions=[], + message_type="text", + ) + sender_id = SimpleNamespace(open_id="ou_user", user_id=None) + self.assertTrue( + adapter._should_accept_group_message(message, sender_id, "oc_chat", member_count=2) + ) + + @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) + def test_member_count_3_requires_mention(self): + """member_count>=3 keeps the standard @mention gate.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + message = SimpleNamespace( + content='{"text":"hello"}', + mentions=[], + message_type="text", + ) + sender_id = SimpleNamespace(open_id="ou_user", user_id=None) + self.assertFalse( + adapter._should_accept_group_message(message, sender_id, "oc_chat", member_count=3) + ) + + @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) + def test_member_count_none_falls_back_to_require_mention_default(self): + """When member_count is unknown (None) the global default (True) applies.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + message = SimpleNamespace( + content='{"text":"hello"}', + mentions=[], + message_type="text", + ) + sender_id = SimpleNamespace(open_id="ou_user", user_id=None) + # No member_count supplied → falls back to global require_mention=True → blocked. + self.assertFalse( + adapter._should_accept_group_message(message, sender_id, "oc_chat", member_count=None) + ) + + # ------------------------------------------------------------------ + # No duplicate processing: @mention in a small group still yields 1 reply + # ------------------------------------------------------------------ + + @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) + def test_member_count_2_with_bot_mention_still_accepted_once(self): + """member_count=2 + explicit @bot mention → passes (no double-fire risk).""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + adapter._bot_open_id = "ou_bot" + mention = SimpleNamespace( + id=SimpleNamespace(open_id="ou_bot", user_id=None), + name="Bot", + ) + message = SimpleNamespace( + content='{"text":"@Bot hello"}', + mentions=[mention], + message_type="text", + ) + sender_id = SimpleNamespace(open_id="ou_user", user_id=None) + # Should pass exactly once — member_count gate fires first (returns True early), + # the @mention check is never reached. + self.assertTrue( + adapter._should_accept_group_message(message, sender_id, "oc_chat", member_count=2) + ) + + # ------------------------------------------------------------------ + # get_chat_info includes member_count when API returns it + # ------------------------------------------------------------------ + + def test_get_chat_info_extracts_member_count(self): + """get_chat_info() stores member_count from API response in cache.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + mock_data = SimpleNamespace( + chat_type="group", + name="Test Group", + member_count=2, + ) + mock_response = SimpleNamespace( + data=mock_data, + success=lambda: True, + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + chat=SimpleNamespace(get=lambda req: mock_response) + ) + ) + ) + with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + info = asyncio.run(adapter.get_chat_info("oc_test")) + + self.assertEqual(info.get("member_count"), 2) + self.assertEqual(info.get("type"), "group") + + def test_get_chat_info_omits_member_count_when_absent(self): + """get_chat_info() doesn't add member_count key when API omits it.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + mock_data = SimpleNamespace( + chat_type="group", + name="No Count", + # member_count intentionally absent + ) + mock_response = SimpleNamespace( + data=mock_data, + success=lambda: True, + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + chat=SimpleNamespace(get=lambda req: mock_response) + ) + ) + ) + with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + info = asyncio.run(adapter.get_chat_info("oc_nocount")) + + self.assertNotIn("member_count", info)