diff --git a/gateway/config.py b/gateway/config.py index c7eb4adf109f..c1554fda7ad4 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -550,6 +550,12 @@ def load_gateway_config() -> GatewayConfig: if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"): os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower() + # Feishu/Lark settings → env vars (env vars take precedence) + feishu_cfg = yaml_cfg.get("feishu", {}) + if isinstance(feishu_cfg, dict): + if "require_mention" in feishu_cfg and not os.getenv("FEISHU_REQUIRE_MENTION"): + os.environ["FEISHU_REQUIRE_MENTION"] = str(feishu_cfg["require_mention"]).lower() + # Telegram settings → env vars (env vars take precedence) telegram_cfg = yaml_cfg.get("telegram", {}) if isinstance(telegram_cfg, dict): diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index d9aaae9a747f..a2279232d55d 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -270,6 +270,7 @@ class FeishuAdapterSettings: webhook_host: str webhook_port: int webhook_path: str + require_mention: bool # Require @mention in group chats (default: true) @dataclass @@ -972,6 +973,25 @@ def __init__(self, config: PlatformConfig): self._pending_media_batch_tasks = self._media_batch_state.tasks self._load_seen_message_ids() + @staticmethod + def _resolve_require_mention(extra: Dict[str, Any]) -> bool: + """Resolve require_mention setting from extra config or env var. + + Uses explicit None check to handle falsy values correctly: + - If extra["require_mention"] exists, use it (even if False) + - Otherwise fallback to FEISHU_REQUIRE_MENTION env var (default: true) + """ + configured = extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + value = configured.strip().lower() + # Default-true semantics: only explicit false-like values disable mention gating. + return value not in ("false", "0", "no", "off") + return bool(configured) + env_value = os.getenv("FEISHU_REQUIRE_MENTION", "true").strip().lower() + # Default-true semantics for env var as well. + return env_value not in ("false", "0", "no", "off") + @staticmethod def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: return FeishuAdapterSettings( @@ -1020,6 +1040,7 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: str(extra.get("webhook_path") or os.getenv("FEISHU_WEBHOOK_PATH", _DEFAULT_WEBHOOK_PATH)).strip() or _DEFAULT_WEBHOOK_PATH ), + require_mention=FeishuAdapter._resolve_require_mention(extra), ) def _apply_settings(self, settings: FeishuAdapterSettings) -> None: @@ -1042,6 +1063,7 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None: self._webhook_host = settings.webhook_host self._webhook_port = settings.webhook_port self._webhook_path = settings.webhook_path + self._require_mention = settings.require_mention def _build_event_handler(self) -> Any: if EventDispatcherHandler is None: @@ -2665,9 +2687,17 @@ def _allow_group_message(self, sender_id: Any) -> bool: return bool(sender_open_id and sender_open_id in self._allowed_group_users) def _should_accept_group_message(self, message: Any, sender_id: Any) -> bool: - """Require an explicit @mention before group messages enter the agent.""" + """Check if a group message should be routed to the agent. + + Uses require_mention setting to control whether @mention is needed: + - require_mention=true (default): Only route if bot is @mentioned or @_all + - require_mention=false: Route all allowed group messages without @mention + """ if not self._allow_group_message(sender_id): return False + # If require_mention is disabled, accept all allowed group messages + 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/hermes_cli/config.py b/hermes_cli/config.py index 17a1226061b9..bc59054f07b0 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -472,6 +472,11 @@ def ensure_hermes_home(): "reactions": True, # Add 👀/✅/❌ reactions to messages during processing }, + # Feishu/Lark platform settings (gateway mode) + "feishu": { + "require_mention": True, # Require @mention to respond in group chats (default: true) + }, + # WhatsApp platform settings (gateway mode) "whatsapp": { # Reply prefix prepended to every outgoing WhatsApp message. diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 5344cda52af0..03c8df0b2e25 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -2474,6 +2474,105 @@ def test_at_all_still_requires_policy_gate(self): self.assertTrue(adapter._should_accept_group_message(message, allowed_sender)) +class TestRequireMentionDisabled(unittest.TestCase): + """Tests for require_mention=false behavior in group chats.""" + + @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 are accepted without @mention.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + message = SimpleNamespace(content='{"text":"hello"}', mentions=[]) + 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": "true"}, + clear=True, + ) + def test_group_message_rejected_without_mention_when_require_mention_true(self): + """Default behavior: require_mention=true requires @mention.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + message = SimpleNamespace(content='{"text":"hello"}', mentions=[]) + 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"}, clear=True) + def test_require_mention_defaults_to_true_when_unset(self): + """When FEISHU_REQUIRE_MENTION is unset, default is true (require mention).""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + message = SimpleNamespace(content='{"text":"hello"}', mentions=[]) + 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": "allowlist", "FEISHU_ALLOWED_USERS": "ou_allowed", "FEISHU_REQUIRE_MENTION": "false"}, + clear=True, + ) + def test_require_mention_false_still_respects_allowlist_policy(self): + """require_mention=false bypasses mention check but NOT allowlist policy.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + message = SimpleNamespace(content='{"text":"hello"}', mentions=[]) + # Allowlisted user — should pass. + allowed_sender = SimpleNamespace(open_id="ou_allowed", user_id=None) + self.assertTrue(adapter._should_accept_group_message(message, allowed_sender)) + # Non-allowlisted user — should be blocked. + blocked_sender = SimpleNamespace(open_id="ou_blocked", user_id=None) + self.assertFalse(adapter._should_accept_group_message(message, blocked_sender)) + + @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) + def test_require_mention_false_from_platform_config_extra(self): + """require_mention=False via PlatformConfig.extra should be honored (no falsy fallback bug).""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + # Explicit False via extra should work even without env var + adapter = FeishuAdapter(PlatformConfig(extra={"require_mention": False})) + message = SimpleNamespace(content='{"text":"hello"}', mentions=[]) + 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"}, clear=True) + def test_require_mention_true_from_platform_config_extra(self): + """require_mention=True via PlatformConfig.extra should be honored.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig(extra={"require_mention": True})) + message = SimpleNamespace(content='{"text":"hello"}', mentions=[]) + 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": "true"}, clear=True) + def test_require_mention_env_var_overridden_by_explicit_false_in_extra(self): + """extra.get("require_mention") takes precedence over env var (even when False).""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + # Env var says true, but extra explicitly says False — should use False + adapter = FeishuAdapter(PlatformConfig(extra={"require_mention": False})) + message = SimpleNamespace(content='{"text":"hello"}', mentions=[]) + sender_id = SimpleNamespace(open_id="ou_any", user_id=None) + self.assertTrue(adapter._should_accept_group_message(message, sender_id)) + + @unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed") class TestSenderNameResolution(unittest.TestCase): """Tests for _resolve_sender_name_from_api."""