{"repo":"NousResearch/hermes-agent","public_id":"5465","display_label":"Feishu group chat mention requirement configuration","summary":"These issues and pull requests concern adding a configurable toggle to control whether the Feishu adapter requires an @mention before responding in group chats. They cover bug reports about missing or ignored config options, feature requests, and implementation work across the adapter and gateway layers.","subsystem":"gateway","counts":{"members":12,"open_issues":0,"open_issues_distinct":0,"open_prs":2,"closed_issues":5,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":18208,"merged_at":"2026-05-01T03:30:32+00:00"},"resolution":{"state":"partially_resolved","by":"pr-18208"},"freshness":{"state":"verified","last_verified_at":"2026-05-01T03:30:32+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2709090909090909},"fix_edges":[{"issue_number":5465,"pr_number":18208,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-01T03:30:32+00:00"},{"issue_number":9835,"pr_number":18208,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-01T03:30:32+00:00"},{"issue_number":10275,"pr_number":18208,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-01T03:30:32+00:00"},{"issue_number":15226,"pr_number":18208,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-01T03:30:32+00:00"}],"members":[{"kind":"issue","number":5465,"title":"Support require_mention: false for group chats in Feishu","url":null,"state":"closed"},{"kind":"issue","number":9835,"title":"[Bug]: Feishu adapter has no require_mention toggle — file messages in group chats are silently dropped","url":null,"state":"closed"},{"kind":"issue","number":10275,"title":"feat(gateway): add FEISHU_REQUIRE_MENTION environment variable for Feishu group chats","url":null,"state":"closed"},{"kind":"issue","number":12801,"title":"[Feature]: Allow Feishu group chats to respond without requiring @mention","url":null,"state":"closed"},{"kind":"issue","number":15226,"title":"Feishu group messages ignore require_group_mention=false from config.yaml","url":null,"state":"closed"},{"kind":"pr","number":10802,"title":"feat(feishu): add FEISHU_REQUIRE_MENTION toggle + hermes upgrade deps validation","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"b94cb8e2c4ebf2a8c7688cf676c3cf9899584adb","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":15227,"title":"Fix Feishu group mention config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d83b41c9e73670396b6b0815913e744023f0b018","number":0},{"kind":"pr","value":"#18208","number":18208},{"kind":"pr","value":"#15226","number":15226}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15525,"title":"fix(gateway): support require_mention toggle for Feishu group chats","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":15937,"title":"feat(feishu): operator-configurable bot admission and mention policy","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18208","number":18208},{"kind":"pr","value":"#10275","number":10275},{"kind":"pr","value":"#15226","number":15226},{"kind":"pr","value":"#9835","number":9835},{"kind":"pr","value":"#5465","number":5465}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16112,"title":"fix(feishu): record non-mentioned group messages as context-only transcript entries","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"552adbe0827c32df8ed9bb19e908c26eff43add7","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":17406,"title":"feat(feishu): allow disabling group mention requirement","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b94cb8e2c4ebf2a8c7688cf676c3cf9899584adb","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":18208,"title":"feat(feishu): operator-configurable bot admission and mention policy","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/feishu.py","gateway/run.py","tests/gateway/feishu_helpers.py","tests/gateway/test_config.py","tests/gateway/test_feishu.py","tests/gateway/test_feishu_bot_admission.py","tests/gateway/test_feishu_bot_auth_bypass.py","website/docs/reference/environment-variables.md","website/docs/user-guide/messaging/feishu.md"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -900,6 +900,12 @@ def load_gateway_config() -> GatewayConfig:\n                 if \"dm_mention_threads\" in matrix_cfg and not os.getenv(\"MATRIX_DM_MENTION_THREADS\"):\n                     os.environ[\"MATRIX_DM_MENTION_THREADS\"] = str(matrix_cfg[\"dm_mention_threads\"]).lower()\n \n+            # Feishu settings → env vars (env vars take precedence)\n+            feishu_cfg = yaml_cfg.get(\"feishu\", {})\n+            if isinstance(feishu_cfg, dict):\n+                if \"allow_bots\" in feishu_cfg and not os.getenv(\"FEISHU_ALLOW_BOTS\"):\n+                    os.environ[\"FEISHU_ALLOW_BOTS\"] = str(feishu_cfg[\"allow_bots\"]).lower()\n+\n     except Exception as e:\n         logger.warning(\n             \"Failed to process config.yaml — falling back to .env / gateway.json values. \""]},{"file":"gateway/platforms/feishu.py","hunks":["@@ -64,7 +64,7 @@\n from datetime import datetime\n from pathlib import Path\n from types import SimpleNamespace\n-from typing import Any, Dict, List, Optional, Sequence\n+from typing import Any, Dict, List, Literal, Optional, Sequence\n from urllib.error import HTTPError, URLError\n from urllib.parse import urlencode\n from urllib.request import Request, urlopen","@@ -388,6 +388,8 @@ class FeishuAdapterSettings:\n     admins: frozenset[str] = frozenset()\n     default_group_policy: str = \"\"\n     group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict)\n+    allow_bots: str = \"none\"  # \"none\" | \"mentions\" | \"all\"\n+    require_mention: bool = True\n \n \n @dataclass","@@ -397,6 +399,7 @@ class FeishuGroupRule:\n     policy: str  # \"open\" | \"allowlist\" | \"blacklist\" | \"admin_only\" | \"disabled\"\n     allowlist: set[str] = field(default_factory=set)\n     blacklist: set[str] = field(default_factory=set)\n+    require_mention: Optional[bool] = None  # None = inherit global\n \n \n @dataclass","@@ -406,6 +409,40 @@ class FeishuBatchState:\n     counts: Dict[str, int] = field(default_factory=dict)\n \n \n+# ---------------------------------------------------------------------------\n+# Admission: policy types\n+# ---------------------------------------------------------------------------\n+\n+\n+RejectReason = Literal[\n+    \"self_echo\",\n+    \"self_ids_unknown\",\n+    \"bots_disabled\",\n+    \"bot_not_mentioned\",\n+    \"group_policy_rejected\",\n+]\n+\n+\n+def _is_bot_sender(sender: Any) -> bool:\n+    # receive_v1 docs say {user, bot}; accept \"app\" defensively.\n+    return getattr(sender, \"sender_type\", \"\") in (\"bot\", \"app\")\n+\n+\n+def _sender_identity(sender: Any) -> frozenset:\n+    # Take any non-empty id variant — tenant sender_id_type decides which are populated.\n+    sid = getattr(sender, \"sender_id\", None)\n+    if sid is None:\n+        return frozenset()\n+    return frozenset(\n+        v for v in (\n+            getattr(sid, \"open_id\", None),\n+            getattr(sid, \"user_id\", None),\n+            getattr(sid, \"union_id\", None),\n+        )\n+        if v\n+    )\n+\n+\n # ---------------------------------------------------------------------------\n # Markdown rendering helpers\n # ---------------------------------------------------------------------------","@@ -1378,10 +1415,16 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:\n             for chat_id, rule_cfg in raw_group_rules.items():\n                 if not isinstance(rule_cfg, dict):\n                     continue\n+                # Only override when the key is explicitly set — missing vs false\n+                # must not collapse.\n+                per_chat_require_mention: Optional[bool] = None\n+                if \"require_mention\" in rule_cfg:\n+                    per_chat_require_mention = _to_boolean(rule_cfg.get(\"require_mention\"))\n                 group_rules[str(chat_id)] = FeishuGroupRule(\n                     policy=str(rule_cfg.get(\"policy\", \"open\")).strip().lower(),\n                     allowlist=set(str(u).strip() for u in rule_cfg.get(\"allowlist\", []) if str(u).strip()),\n                     blacklist=set(str(u).strip() for u in rule_cfg.get(\"blacklist\", []) if str(u).strip()),\n+                    require_mention=per_chat_require_mention,\n                 )\n \n         # Bot-level admins","@@ -1391,6 +1434,16 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:\n         # Default group policy (for groups not in group_rules)\n         default_group_policy = str(extra.get(\"default_group_policy\", \"\")).strip().lower()\n \n+        # Env-only so adapter and gateway auth bypass share one source; yaml\n+        # feishu.allow_bots is bridged to this env var at config load.\n+        allow_bots = os.getenv(\"FEISHU_ALLOW_BOTS\", \"none\").strip().lower()\n+        if allow_bots not in (\"none\", \"mentions\", \"all\"):\n+            logger.warning(\n+                \"[Feishu] Unknown allow_bots=%r, falling back to 'none'. Valid: none, mentions, all.\",\n+                allow_bots,\n+            )\n+            allow_bots = \"none\"\n+\n         return FeishuAdapterSettings(\n             app_id=str(extra.get(\"app_id\") or os.getenv(\"FEISHU_APP_ID\", \"\")).strip(),\n             app_secret=[redacted:secret-assignment]\"app_secret\") or os.getenv(\"FEISHU_APP_SECRET\", \"\")).strip(),","@@ -1447,6 +1500,10 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:\n             admins=admins,\n             default_group_policy=default_group_policy,\n             group_rules=group_rules,\n+            allow_bots=allow_bots,\n+            require_mention=_to_boolean(\n+                extra.get(\"require_mention\", os.getenv(\"FEISHU_REQUIRE_MENTION\", \"true\"))\n+            ),\n         )\n \n     def _apply_settings(self, settings: FeishuAdapterSettings) -> None:","@@ -1477,6 +1534,8 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None:\n         self._ws_reconnect_interval = settings.ws_reconnect_interval\n         self._ws_ping_interval = settings.ws_ping_interval\n         self._ws_ping_timeout = settings.ws_ping_timeout\n+        self._allow_bots = settings.allow_bots\n+        self._require_mention = settings.require_mention\n \n     def _build_event_handler(self) -> Any:\n         if EventDispatcherHandler is None:","@@ -2190,30 +2249,28 @@ async def _handle_message_event_data(self, data: Any) -> None:\n         event = getattr(data, \"event\", None)\n         message = getattr(event, \"message\", None)\n         sender = getattr(event, \"sender\", None)\n-        sender_id = getattr(sender, \"sender_id\", None)\n-        if not message or not sender_id:\n-            logger.debug(\"[Feishu] Dropping malformed inbound event: missing message or sender_id\")\n+        if not message or not sender or not getattr(sender, \"sender_id\", None):\n+            logger.debug(\"[Feishu] Dropping malformed inbound event: missing message/sender\")\n             return\n \n         message_id = getattr(message, \"message_id\", None)\n         if not message_id or self._is_duplicate(message_id):\n             logger.debug(\"[Feishu] Dropping duplicate/missing message_id: %s\", message_id)\n             return\n-        if self._is_self_sent_bot_message(event):\n-            logger.debug(\"[Feishu] Dropping self-sent bot event: %s\", message_id)\n+\n+        reason = self._admit(sender, message)\n+        if reason is not None:\n+            logger.debug(\"[Feishu] dropping inbound event: %s\", reason)\n             return\n \n         chat_type = getattr(message, \"chat_type\", \"p2p\")\n-        chat_id = getattr(message, \"chat_id\", \"\") or \"\"\n-        if chat_type != \"p2p\" and not self._should_accept_group_message(message, sender_id, chat_id):\n-            logger.debug(\"[Feishu] Dropping group message that failed mention/policy gate: %s\", message_id)\n-            return\n         await self._process_inbound_message(\n             data=data,\n             message=message,\n-            sender_id=sender_id,\n+            sender_id=getattr(sender, \"sender_id\", None),\n             chat_type=chat_type,\n             message_id=message_id,\n+            is_bot=_is_bot_sender(sender),\n         )\n \n     def _on_message_read_event(self, data: P2ImMessageMessageReadV1) -> None:","@@ -2390,10 +2447,11 @@ async def _handle_reaction_event(self, event_type: str, data: Any) -> None:\n             msg = items[0] if items else None\n             if not msg:\n                 return\n+            # GET im/v1/messages returns sender.id=app_id for bot messages —\n+            # peer bots and us share sender_type=\"app\" but differ on app_id.\n             sender = getattr(msg, \"sender\", None)\n-            sender_type = str(getattr(sender, \"sender_type\", \"\") or \"\").lower()\n-            if sender_type != \"app\":\n-                return  # only route reactions on our own bot messages\n+            if str(getattr(sender, \"id\", \"\") or \"\") != self._app_id:\n+                return  # only route reactions on this bot's own messages\n             chat_id = str(getattr(msg, \"chat_id\", \"\") or \"\")\n             chat_type_raw = str(getattr(msg, \"chat_type\", \"p2p\") or \"p2p\")\n             if not chat_id:","@@ -2680,6 +2738,7 @@ async def _process_inbound_message(\n         sender_id: Any,\n         chat_type: str,\n         message_id: str,\n+        is_bot: bool = False,\n     ) -> None:\n         text, inbound_type, media_urls, media_types, mentions = await self._extract_message_content(message)\n ","@@ -2705,19 +2764,27 @@ async def _process_inbound_message(\n         )\n         reply_to_text = await self._fetch_message_text(reply_to_message_id) if reply_to_message_id else None\n \n+        sender_primary = (\n+            getattr(sender_id, \"open_id\", None)\n+            or getattr(sender_id, \"user_id\", None)\n+            or getattr(sender_id, \"union_id\", None)\n+            or \"<unknown>\"\n+        )\n         logger.info(\n-            \"[Feishu] Inbound %s message received: id=%s type=%s chat_id=%s text=%r media=%d\",\n+            \"[Feishu] Inbound %s message received: id=%s type=%s chat_id=%s sender=%s:%s text=%r media=%d\",\n             \"dm\" if chat_type == \"p2p\" else \"group\",\n             message_id,\n             inbound_type.value,\n             getattr(message, \"chat_id\", \"\") or \"\",\n+            \"bot\" if is_bot else \"user\",\n+            sender_primary,\n             text[:120],\n             len(media_urls),\n         )\n \n         chat_id = getattr(message, \"chat_id\", \"\") or \"\"\n         chat_info = await self.get_chat_info(chat_id)\n-        sender_profile = await self._resolve_sender_profile(sender_id)\n+        sender_profile = await self._resolve_sender_profile(sender_id, is_bot=is_bot)\n         source = self.build_source(\n             chat_id=chat_id,\n             chat_name=chat_info.get(\"name\") or chat_id or \"Feishu Chat\",","@@ -2726,6 +2793,7 @@ async def _process_inbound_message(\n             user_name=sender_profile[\"user_name\"],\n             thread_id=getattr(message, \"thread_id\", None) or None,\n             user_id_alt=sender_profile[\"user_id_alt\"],\n+            is_bot=is_bot,\n         )\n         normalized = MessageEvent(\n             text=text,","@@ -3448,7 +3516,12 @@ def _resolve_source_chat_type(*, chat_info: Dict[str, Any], event_chat_type: str\n             return \"dm\"\n         return \"group\"\n \n-    async def _resolve_sender_profile(self, sender_id: Any) -> Dict[str, Optional[str]]:\n+    async def _resolve_sender_profile(\n+        self,\n+        sender_id: Any,\n+        *,\n+        is_bot: bool = False,\n+    ) -> Dict[str, Optional[str]]:\n         \"\"\"Map Feishu's three-tier user IDs onto Hermes' SessionSource fields.\n \n         Preference order for the primary ``user_id`` field:","@@ -3465,7 +3538,11 @@ async def _resolve_sender_profile(self, sender_id: Any) -> Dict[str, Optional[st\n         union_id = getattr(sender_id, \"union_id\", None) or None\n         # Prefer tenant-scoped user_id; fall back to app-scoped open_id.\n         primary_id = user_id or open_id\n-        display_name = await self._resolve_sender_name_from_api(primary_id or union_id)\n+        # bot/v3/bots/basic_batch only accepts open_id.\n+        name_lookup_id = open_id if is_bot else (primary_id or union_id)\n+        display_name = await self._resolve_sender_name_from_api(\n+            name_lookup_id, is_bot=is_bot,\n+        )\n         return {\n             \"user_id\": primary_id,\n             \"user_name\": display_name,","@@ -3485,11 +3562,14 @@ def _get_cached_sender_name(self, sender_id: Optional[str]) -> Optional[str]:\n         self._sender_name_cache.pop(sender_id, None)\n         return None\n \n-    async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optional[str]:\n-        \"\"\"Fetch the sender's display name from the Feishu contact API with a 10-minute cache.\n-\n-        ID-type detection mirrors openclaw: ou_ → open_id, on_ → union_id, else user_id.\n-        Failures are silently suppressed; the message pipeline must not block on name resolution.\n+    async def _resolve_sender_name_from_api(\n+        self,\n+        sender_id: Optional[str],\n+        *,\n+        is_bot: bool = False,\n+    ) -> Optional[str]:\n+        \"\"\"Bots divert to bot/basic_batch — contact API doesn't return bot names.\n+        Failures are silent so the pipeline never blocks on name resolution.\n         \"\"\"\n         if not sender_id or not self._client:\n             return None","@@ -3499,7 +3579,16 @@ async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optio\n         now = time.time()\n         cached_name = self._get_cached_sender_name(trimmed)\n         if cached_name is not None:\n-            return cached_name\n+            return cached_name or None  # \"\" cached means \"known nameless\"\n+        if is_bot:\n+            names = await self._fetch_bot_names([trimmed])\n+            if names is None:\n+                return None\n+            expire_at = now + _FEISHU_SENDER_NAME_TTL_SECONDS\n+            for oid, name in names.items():\n+                self._sender_name_cache[oid] = (name, expire_at)\n+            hit = self._sender_name_cache.get(trimmed)\n+            return (hit[0] or None) if hit else None\n         try:\n             from lark_oapi.api.contact.v3 import GetUserRequest  # lazy import\n             if trimmed.startswith(\"ou_\"):","@@ -3528,6 +3617,35 @@ async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optio\n             logger.debug(\"[Feishu] Failed to resolve sender name for %s\", sender_id, exc_info=True)\n         return None\n \n+    async def _fetch_bot_names(self, bot_ids: List[str]) -> Optional[Dict[str, str]]:\n+        if not self._client or not bot_ids:\n+            return None\n+        try:\n+            req = (\n+                BaseRequest.builder()\n+                .http_method(HttpMethod.GET)\n+                .uri(\"/open-apis/bot/v3/bots/basic_batch\")\n+                .queries([(\"bot_ids\", oid) for oid in bot_ids])\n+                .token_types({AccessTokenType.TENANT})\n+                .build()\n+            )\n+            resp = await asyncio.to_thread(self._client.request, req)\n+            content = getattr(getattr(resp, \"raw\", None), \"content\", None)\n+            if not content:\n+                return None\n+            payload = json.loads(content)\n+            if payload.get(\"code\") != 0:\n+                return None\n+            bots = (payload.get(\"data\") or {}).get(\"bots\") or {}\n+            return {\n+                oid: str(info.get(\"name\") or \"\").strip()\n+                for oid, info in bots.items()\n+                if oid\n+            }\n+        except Exception:\n+            logger.debug(\"[Feishu] Failed to fetch bot names for %s\", bot_ids, exc_info=True)\n+            return None\n+\n     async def _fetch_message_text(self, message_id: str) -> Optional[str]:\n         if not self._client or not message_id:\n             return None","@@ -3591,10 +3709,60 @@ def _log_background_failure(future: Any) -> None:\n             logger.exception(\"[Feishu] Background inbound processing failed\")\n \n     # =========================================================================\n-    # Group policy and mention gating\n+    # Inbound admission\n     # =========================================================================\n \n-    def _allow_group_message(self, sender_id: Any, chat_id: str = \"\") -> bool:\n+    def _admit(self, sender: Any, message: Any) -> Optional[RejectReason]:\n+        sender_ids = _sender_identity(sender)\n+        self_ids = frozenset(v for v in (self._bot_open_id, self._bot_user_id) if v)\n+        is_bot = _is_bot_sender(sender)\n+        is_group = getattr(message, \"chat_type\", \"p2p\") != \"p2p\"\n+        chat_id = getattr(message, \"chat_id\", \"\") or \"\"\n+        require_mention = is_group and self._require_mention_for(chat_id)\n+\n+        # Defensive only — Feishu doesn't echo our outbound back as inbound,\n+        # and open_id is always populated on both sides.\n+        if self_ids and sender_ids & self_ids:\n+            return \"self_echo\"\n+\n+        if is_bot:\n+            mode = self._allow_bots\n+            if mode != \"mentions\" and mode != \"all\":\n+                return \"bots_disabled\"\n+            # Defensive: pre-hydration or malformed payloads.\n+            if not self_ids or not sender_ids:\n+                return \"self_ids_unknown\"\n+            # Step 4 covers mention enforcement for groups when require_mention\n+            # is on; check here only on paths step 4 won't reach.\n+            if mode == \"mentions\" and not require_mention and not self._mentions_self(message):\n+                return \"bot_not_mentioned\"\n+\n+        if not is_group:\n+            return None\n+\n+        if not self._allow_group_message(\n+            getattr(sender, \"sender_id\", None), chat_id, is_bot=is_bot,\n+        ):\n+            return \"group_policy_rejected\"\n+        if require_mention and not self._mentions_self(message):\n+            return \"group_policy_rejected\"\n+        return None\n+\n+    def _require_mention_for(self, chat_id: str) -> bool:\n+        rule = self._group_rules.get(chat_id) if chat_id else None\n+        if rule and rule.require_mention is not None:\n+            return rule.require_mention\n+        return self._require_mention\n+\n+    # --- Group policy ---------------------------------------------------------\n+\n+    def _allow_group_message(\n+        self,\n+        sender_id: Any,\n+        chat_id: str = \"\",\n+        *,\n+        is_bot: bool = False,\n+    ) -> bool:\n         \"\"\"Per-group policy gate for non-DM traffic.\"\"\"\n         sender_open_id = getattr(sender_id, \"open_id\", None)\n         sender_user_id = getattr(sender_id, \"user_id\", None)","@@ -3613,12 +3781,17 @@ def _allow_group_message(self, sender_id: Any, chat_id: str = \"\") -> bool:\n             allowlist = self._allowed_group_users\n             blacklist = set()\n \n+        # Channel locks apply to everyone; allowlist/blacklist only gate humans\n+        # (bots were already cleared upstream by FEISHU_ALLOW_BOTS).\n         if policy == \"disabled\":\n             return False\n         if policy == \"open\":\n             return True\n         if policy == \"admin_only\":\n             return False\n+        if is_bot:\n+            return True\n+\n         if policy == \"allowlist\":\n             return bool(sender_ids and (sender_ids & allowlist))\n         if policy == \"blacklist\":","@@ -3626,17 +3799,16 @@ def _allow_group_message(self, sender_id: Any, chat_id: str = \"\") -> bool:\n \n         return bool(sender_ids and (sender_ids & self._allowed_group_users))\n \n-    def _should_accept_group_message(self, message: Any, sender_id: Any, chat_id: str = \"\") -> bool:\n-        \"\"\"Require an explicit @mention before group messages enter the agent.\"\"\"\n-        if not self._allow_group_message(sender_id, chat_id):\n-            return False\n-        # @_all is Feishu's @everyone placeholder — always route to the bot.\n+    # --- Mention detection ----------------------------------------------------\n+\n+    def _mentions_self(self, message: Any) -> bool:\n+        # @_all is Feishu's @everyone placeholder.\n         raw_content = getattr(message, \"content\", \"\") or \"\"\n         if \"@_all\" in raw_content:\n             return True\n         mentions = getattr(message, \"mentions\", None) or []\n-        if mentions:\n-            return self._message_mentions_bot(mentions)\n+        if mentions and self._message_mentions_bot(mentions):\n+            return True\n         normalized = normalize_feishu_message(\n             message_type=getattr(message, \"message_type\", \"\") or \"\",\n             raw_content=raw_content,","@@ -3645,23 +3817,6 @@ def _should_accept_group_message(self, message: Any, sender_id: Any, chat_id: st\n         )\n         return self._post_mentions_bot(normalized.mentions)\n \n-    def _is_self_sent_bot_message(self, event: Any) -> bool:\n-        \"\"\"Return True only for Feishu events emitted by this Hermes bot.\"\"\"\n-        sender = getattr(event, \"sender\", None)\n-        sender_type = str(getattr(sender, \"sender_type\", \"\") or \"\").strip().lower()\n-        if sender_type not in {\"bot\", \"app\"}:\n-            return False\n-\n-        sender_id = getattr(sender, \"sender_id\", None)\n-        sender_open_id = str(getattr(sender_id, \"open_id\", \"\") or \"\").strip()\n-        sender_user_id = str(getattr(sender_id, \"user_id\", \"\") or \"\").strip()\n-\n-        if self._bot_open_id and sender_open_id == self._bot_open_id:\n-            return True\n-        if self._bot_user_id and sender_user_id == self._bot_user_id:\n-            return True\n-        return False\n-\n     def _message_mentions_bot(self, mentions: List[Any]) -> bool:\n         # IDs trump names: when both sides have open_id (or both user_id),\n         # match requires equal IDs. Name fallback only when either side"]},{"file":"gateway/run.py","hunks":["@@ -3958,6 +3958,11 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n             Platform.QQBOT: \"QQ_ALLOW_ALL_USERS\",\n             Platform.YUANBAO: \"YUANBAO_ALLOW_ALL_USERS\",\n         }\n+        # Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist (#4466).\n+        platform_allow_bots_map = {\n+            Platform.DISCORD: \"DISCORD_ALLOW_BOTS\",\n+            Platform.FEISHU: \"FEISHU_ALLOW_BOTS\",\n+        }\n \n         # Plugin platforms: check the registry for auth env var names\n         if source.platform not in platform_env_map:","@@ -3977,14 +3982,9 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n         if platform_allow_all_var and os.getenv(platform_allow_all_var, \"\").lower() in (\"true\", \"1\", \"yes\"):\n             return True\n \n-        # Discord bot senders that passed the DISCORD_ALLOW_BOTS platform\n-        # filter are already authorized at the platform level — skip the\n-        # user allowlist. Without this, bot messages allowed by\n-        # DISCORD_ALLOW_BOTS=mentions/all would be rejected here with\n-        # \"Unauthorized user\" (fixes #4466).\n-        if source.platform == Platform.DISCORD and getattr(source, \"is_bot\", False):\n-            allow_bots = os.getenv(\"DISCORD_ALLOW_BOTS\", \"none\").lower().strip()\n-            if allow_bots in (\"mentions\", \"all\"):\n+        if getattr(source, \"is_bot\", False):\n+            allow_bots_var = platform_allow_bots_map.get(source.platform)\n+            if allow_bots_var and os.getenv(allow_bots_var, \"none\").lower().strip() in (\"mentions\", \"all\"):\n                 return True\n \n         # Discord role-based access (DISCORD_ALLOWED_ROLES): the adapter's"]},{"file":"tests/gateway/feishu_helpers.py","hunks":["@@ -0,0 +1,65 @@\n+\"\"\"Shared fixtures for Feishu adapter tests (admission, group policy, dispatch).\"\"\"\n+\n+from __future__ import annotations\n+\n+import threading\n+from types import SimpleNamespace\n+from typing import Any, Optional\n+\n+\n+def make_sender(sender_type: str = \"user\", open_id: str = \"ou_human\",\n+                user_id: Optional[str] = None, union_id: Optional[str] = None) -> Any:\n+    return SimpleNamespace(\n+        sender_type=sender_type,\n+        sender_id=SimpleNamespace(open_id=open_id, user_id=user_id, union_id=union_id),\n+    )\n+\n+\n+def make_message(message_id: str = \"om_xxx\", chat_type: str = \"p2p\",\n+                 chat_id: str = \"oc_1\", mentions: Optional[list] = None) -> Any:\n+    return SimpleNamespace(\n+        message_id=message_id,\n+        chat_type=chat_type,\n+        chat_id=chat_id,\n+        mentions=mentions,\n+        content=\"\",\n+        message_type=\"text\",\n+    )\n+\n+\n+def make_adapter_skeleton(\n+    *,\n+    bot_open_id: str = \"ou_me\",\n+    bot_user_id: str = \"\",\n+    allow_bots: str = \"none\",\n+    require_mention: bool = True,\n+    group_policy: str = \"allowlist\",\n+) -> Any:\n+    from gateway.platforms.feishu import FeishuAdapter\n+\n+    adapter = object.__new__(FeishuAdapter)\n+    adapter._bot_open_id = bot_open_id\n+    adapter._bot_user_id = bot_user_id\n+    adapter._bot_name = \"\"\n+    adapter._app_id = \"\"\n+    adapter._admins = set()\n+    adapter._group_rules = {}\n+    adapter._group_policy = group_policy\n+    adapter._default_group_policy = group_policy\n+    adapter._allowed_group_users = frozenset()\n+    adapter._allow_bots = allow_bots\n+    adapter._require_mention = require_mention\n+    return adapter\n+\n+\n+def install_dedup_state(adapter: Any, seen: Optional[dict] = None) -> None:\n+    adapter._seen_message_ids = dict(seen) if seen else {}\n+    adapter._seen_message_order = list((seen or {}).keys())\n+    adapter._dedup_cache_size = 100\n+    adapter._dedup_lock = threading.Lock()\n+    adapter._dedup_state_path = None\n+    adapter._persist_seen_message_ids = lambda: None\n+\n+\n+def stub_mention(adapter: Any, mentions_self: bool) -> None:\n+    adapter._mentions_self = lambda _message: mentions_self"]},{"file":"tests/gateway/test_config.py","hunks":["@@ -360,6 +360,38 @@ def test_bridges_slack_channel_prompts_from_config_yaml(self, tmp_path, monkeypa\n             \"C01ABC\": \"Code review mode\",\n         }\n \n+    def test_bridges_feishu_allow_bots_from_config_yaml_to_env(self, tmp_path, monkeypatch):\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        config_path = hermes_home / \"config.yaml\"\n+        config_path.write_text(\n+            \"feishu:\\n  allow_bots: mentions\\n\",\n+            encoding=\"utf-8\",\n+        )\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+        monkeypatch.delenv(\"FEISHU_ALLOW_BOTS\", raising=False)\n+\n+        load_gateway_config()\n+\n+        assert os.environ.get(\"FEISHU_ALLOW_BOTS\") == \"mentions\"\n+\n+    def test_feishu_allow_bots_env_takes_precedence_over_config_yaml(self, tmp_path, monkeypatch):\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        config_path = hermes_home / \"config.yaml\"\n+        config_path.write_text(\n+            \"feishu:\\n  allow_bots: all\\n\",\n+            encoding=\"utf-8\",\n+        )\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+        monkeypatch.setenv(\"FEISHU_ALLOW_BOTS\", \"none\")\n+\n+        load_gateway_config()\n+\n+        assert os.environ.get(\"FEISHU_ALLOW_BOTS\") == \"none\"\n+\n     def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch):\n         hermes_home = tmp_path / \".hermes\"\n         hermes_home.mkdir()"]},{"file":"tests/gateway/test_feishu.py","hunks":["@@ -8,6 +8,7 @@\n import unittest\n from pathlib import Path\n from types import SimpleNamespace\n+from typing import Dict\n from unittest.mock import AsyncMock, Mock, patch\n \n from gateway.platforms.base import ProcessingOutcome","@@ -557,6 +558,16 @@ def start(self):\n         self.assertEqual(fake_client._ping_interval, 4)\n \n \n+def _admits_group(adapter, message, sender_id, chat_id=\"\"):\n+    \"\"\"Group-path shim: run a message through ``_admit`` and return a bool.\"\"\"\n+    sender = SimpleNamespace(sender_type=\"user\", sender_id=sender_id)\n+    if not hasattr(message, \"chat_type\"):\n+        message.chat_type = \"group\"\n+    if chat_id:\n+        message.chat_id = chat_id\n+    return adapter._admit(sender, message) is None\n+\n+\n class TestAdapterBehavior(unittest.TestCase):\n     @patch.dict(os.environ, {}, clear=True)\n     def test_build_event_handler_registers_reaction_and_card_processors(self):","@@ -689,6 +700,67 @@ def _close_coro_and_return_future(coro, _loop):\n             adapter._on_reaction_event(\"im.message.reaction.created_v1\", data)\n         run_threadsafe.assert_called_once()\n \n+    def _build_reaction_adapter(self, *, msg_sender_id: str):\n+        \"\"\"Build a FeishuAdapter wired up to return a single GET-message result.\"\"\"\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._app_id = \"cli_self_app\"\n+        adapter._bot_open_id = \"ou_self_bot\"\n+        adapter._bot_user_id = \"u_self_bot\"\n+\n+        msg = SimpleNamespace(\n+            sender=SimpleNamespace(sender_type=\"app\", id=msg_sender_id, id_type=\"app_id\"),\n+            chat_id=\"oc_chat\",\n+            chat_type=\"group\",\n+        )\n+        response = SimpleNamespace(success=lambda: True, data=SimpleNamespace(items=[msg]))\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(message=SimpleNamespace(get=Mock(return_value=response)))\n+            )\n+        )\n+        adapter._build_get_message_request = Mock(return_value=object())\n+        adapter._handle_message_with_guards = AsyncMock()\n+        adapter._resolve_sender_profile = AsyncMock(\n+            return_value={\"user_id\": \"u_human\", \"user_name\": \"Human\", \"user_id_alt\": None}\n+        )\n+        adapter.get_chat_info = AsyncMock(return_value={\"name\": \"Test Chat\"})\n+        return adapter\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_reaction_on_peer_bot_message_is_not_routed(self):\n+        # GET im/v1/messages sender for bot messages carries id=app_id; a peer\n+        # bot's message has a different app_id than ours, so it must be dropped.\n+        adapter = self._build_reaction_adapter(msg_sender_id=\"cli_peer_app\")\n+\n+        event = SimpleNamespace(\n+            message_id=\"om_peer_msg\",\n+            user_id=SimpleNamespace(open_id=\"ou_human\", user_id=None, union_id=None),\n+            reaction_type=SimpleNamespace(emoji_type=\"THUMBSUP\"),\n+        )\n+        data = SimpleNamespace(event=event)\n+        asyncio.run(\n+            adapter._handle_reaction_event(\"im.message.reaction.created_v1\", data)\n+        )\n+        adapter._handle_message_with_guards.assert_not_awaited()\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_reaction_on_our_own_bot_message_is_routed(self):\n+        adapter = self._build_reaction_adapter(msg_sender_id=\"cli_self_app\")\n+\n+        event = SimpleNamespace(\n+            message_id=\"om_self_msg\",\n+            user_id=SimpleNamespace(open_id=\"ou_human\", user_id=None, union_id=None),\n+            reaction_type=SimpleNamespace(emoji_type=\"THUMBSUP\"),\n+        )\n+        data = SimpleNamespace(event=event)\n+        asyncio.run(\n+            adapter._handle_reaction_event(\"im.message.reaction.created_v1\", data)\n+        )\n+        adapter._handle_message_with_guards.assert_awaited_once()\n+\n     @patch.dict(os.environ, {\"FEISHU_GROUP_POLICY\": \"open\"}, clear=True)\n     def test_group_message_requires_mentions_even_when_policy_open(self):\n         from gateway.config import PlatformConfig","@@ -697,10 +769,10 @@ def test_group_message_requires_mentions_even_when_policy_open(self):\n         adapter = FeishuAdapter(PlatformConfig())\n         message = SimpleNamespace(mentions=[])\n         sender_id = SimpleNamespace(open_id=\"ou_any\", user_id=None)\n-        self.assertFalse(adapter._should_accept_group_message(message, sender_id, \"\"))\n+        self.assertFalse(_admits_group(adapter, message, sender_id, \"\"))\n \n         message_with_mention = SimpleNamespace(mentions=[SimpleNamespace(key=\"@_user_1\")])\n-        self.assertFalse(adapter._should_accept_group_message(message_with_mention, sender_id, \"\"))\n+        self.assertFalse(_admits_group(adapter, message_with_mention, sender_id, \"\"))\n \n     @patch.dict(os.environ, {\"FEISHU_GROUP_POLICY\": \"open\"}, clear=True)\n     def test_group_message_with_other_user_mention_is_rejected_when_bot_identity_unknown(self):","@@ -714,59 +786,10 @@ def test_group_message_with_other_user_mention_is_rejected_when_bot_identity_unk\n             id=SimpleNamespace(open_id=\"ou_other\", user_id=\"u_other\"),\n         )\n \n-        self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id, \"\"))\n-\n-    @patch.dict(\n-        os.environ,\n-        {\n-            \"FEISHU_BOT_OPEN_ID\": \"ou_hermes\",\n-            \"FEISHU_BOT_USER_ID\": \"u_hermes\",\n-        },\n-        clear=True,\n-    )\n-    def test_other_bot_sender_is_not_treated_as_self_sent_message(self):\n-        from gateway.config import PlatformConfig\n-        from gateway.platforms.feishu import FeishuAdapter\n-\n-        adapter = FeishuAdapter(PlatformConfig())\n-        event = SimpleNamespace(\n-            sender=SimpleNamespace(\n-                sender_type=\"bot\",\n-                sender_id=SimpleNamespace(open_id=\"ou_other_bot\", user_id=\"u_other_bot\"),\n-            )\n-        )\n-\n-        self.assertFalse(adapter._is_self_sent_bot_message(event))\n-\n-    @patch.dict(\n-        os.environ,\n-        {\n-            \"FEISHU_BOT_OPEN_ID\": \"ou_hermes\",\n-            \"FEISHU_BOT_USER_ID\": \"u_hermes\",\n-        },\n-        clear=True,\n-    )\n-    def test_self_bot_sender_is_treated_as_self_sent_message(self):\n-        from gateway.config import PlatformConfig\n-        from gateway.platforms.feishu import FeishuAdapter\n-\n-        adapter = FeishuAdapter(PlatformConfig())\n-        by_open_id = SimpleNamespace(\n-            sender=SimpleNamespace(\n-                sender_type=\"bot\",\n-                sender_id=SimpleNamespace(open_id=\"ou_hermes\", user_id=\"u_other\"),\n-            )\n-        )\n-        by_user_id = SimpleNamespace(\n-            sender=SimpleNamespace(\n-                sender_type=\"app\",\n-                sender_id=SimpleNamespace(open_id=\"ou_other\", user_id=\"u_hermes\"),\n-            )\n+        self.assertFalse(\n+            _admits_group(adapter, SimpleNamespace(mentions=[other_mention]), sender_id, \"\")\n         )\n \n-        self.assertTrue(adapter._is_self_sent_bot_message(by_open_id))\n-        self.assertTrue(adapter._is_self_sent_bot_message(by_user_id))\n-\n     @patch.dict(\n         os.environ,\n         {","@@ -792,14 +815,14 @@ def test_group_message_allowlist_and_mention_both_required(self):\n         )\n \n         self.assertTrue(\n-            adapter._should_accept_group_message(\n+            _admits_group(adapter,\n                 mentioned,\n                 SimpleNamespace(open_id=\"ou_allowed\", user_id=None),\n                 \"\",\n             )\n         )\n         self.assertFalse(\n-            adapter._should_accept_group_message(\n+            _admits_group(adapter,\n                 mentioned,\n                 SimpleNamespace(open_id=\"ou_blocked\", user_id=None),\n                 \"\",","@@ -828,14 +851,14 @@ def test_per_group_allowlist_policy_gates_by_sender(self):\n         )\n \n         self.assertTrue(\n-            adapter._should_accept_group_message(\n+            _admits_group(adapter,\n                 message,\n                 SimpleNamespace(open_id=\"ou_alice\", user_id=None),\n                 \"oc_chat_a\",\n             )\n         )\n         self.assertFalse(\n-            adapter._should_accept_group_message(\n+            _admits_group(adapter,\n                 message,\n                 SimpleNamespace(open_id=\"ou_charlie\", user_id=None),\n                 \"oc_chat_a\",","@@ -864,14 +887,14 @@ def test_per_group_blacklist_policy_blocks_specific_users(self):\n         )\n \n         self.assertTrue(\n-            adapter._should_accept_group_message(\n+            _admits_group(adapter,\n                 message,\n                 SimpleNamespace(open_id=\"ou_alice\", user_id=None),\n                 \"oc_chat_b\",\n             )\n         )\n         self.assertFalse(\n-            adapter._should_accept_group_message(\n+            _admits_group(adapter,\n                 message,\n                 SimpleNamespace(open_id=\"ou_blocked\", user_id=None),\n                 \"oc_chat_b\",","@@ -900,14 +923,14 @@ def test_per_group_admin_only_policy_requires_admin(self):\n         )\n \n         self.assertTrue(\n-            adapter._should_accept_group_message(\n+            _admits_group(adapter,\n                 message,\n                 SimpleNamespace(open_id=\"ou_admin\", user_id=None),\n                 \"oc_chat_c\",\n             )\n         )\n         self.assertFalse(\n-            adapter._should_accept_group_message(\n+            _admits_group(adapter,\n                 message,\n                 SimpleNamespace(open_id=\"ou_regular\", user_id=None),\n                 \"oc_chat_c\",","@@ -936,14 +959,14 @@ def test_per_group_disabled_policy_blocks_all(self):\n         )\n \n         self.assertTrue(\n-            adapter._should_accept_group_message(\n+            _admits_group(adapter,\n                 message,\n                 SimpleNamespace(open_id=\"ou_admin\", user_id=None),\n                 \"oc_chat_d\",\n             )\n         )\n         self.assertFalse(\n-            adapter._should_accept_group_message(\n+            _admits_group(adapter,\n                 message,\n                 SimpleNamespace(open_id=\"ou_regular\", user_id=None),\n                 \"oc_chat_d\",","@@ -973,7 +996,7 @@ def test_global_admins_bypass_all_group_rules(self):\n         )\n \n         self.assertTrue(\n-            adapter._should_accept_group_message(\n+            _admits_group(adapter,\n                 message,\n                 SimpleNamespace(open_id=\"ou_admin\", user_id=None),\n                 \"oc_chat_e\",","@@ -997,7 +1020,7 @@ def test_default_group_policy_fallback_for_chats_without_explicit_rule(self):\n         )\n \n         self.assertTrue(\n-            adapter._should_accept_group_message(\n+            _admits_group(adapter,\n                 message,\n                 SimpleNamespace(open_id=\"ou_anyone\", user_id=None),\n                 \"oc_chat_unknown\",","@@ -1022,8 +1045,12 @@ def test_group_message_matches_bot_open_id_when_configured(self):\n             id=SimpleNamespace(open_id=\"ou_other\", user_id=\"u_other\"),\n         )\n \n-        self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[bot_mention]), sender_id, \"\"))\n-        self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id, \"\"))\n+        self.assertTrue(\n+            _admits_group(adapter, SimpleNamespace(mentions=[bot_mention]), sender_id, \"\")\n+        )\n+        self.assertFalse(\n+            _admits_group(adapter, SimpleNamespace(mentions=[other_mention]), sender_id, \"\")\n+        )\n \n     @patch.dict(os.environ, {\"FEISHU_GROUP_POLICY\": \"open\"}, clear=True)\n     def test_group_message_matches_bot_name_when_only_name_available(self):","@@ -1048,8 +1075,12 @@ def test_group_message_matches_bot_name_when_only_name_available(self):\n             id=SimpleNamespace(open_id=None, user_id=None),\n         )\n \n-        self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[name_only_mention]), sender_id, \"\"))\n-        self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[different_mention]), sender_id, \"\"))\n+        self.assertTrue(\n+            _admits_group(adapter, SimpleNamespace(mentions=[name_only_mention]), sender_id, \"\")\n+        )\n+        self.assertFalse(\n+            _admits_group(adapter, SimpleNamespace(mentions=[different_mention]), sender_id, \"\")\n+        )\n \n         # Case 2: bot's open_id IS known — a same-name human with different\n         # open_id must NOT admit (IDs override names).","@@ -1066,8 +1097,17 @@ def test_group_message_matches_bot_name_when_only_name_available(self):\n             id=SimpleNamespace(open_id=\"ou_bot\", user_id=None),\n         )\n \n-        self.assertFalse(adapter2._should_accept_group_message(SimpleNamespace(mentions=[same_name_other_id_mention]), sender_id, \"\"))\n-        self.assertTrue(adapter2._should_accept_group_message(SimpleNamespace(mentions=[bot_mention]), sender_id, \"\"))\n+        self.assertFalse(\n+            _admits_group(\n+                adapter2,\n+                SimpleNamespace(mentions=[same_name_other_id_mention]),\n+                sender_id,\n+                \"\",\n+            )\n+        )\n+        self.assertTrue(\n+            _admits_group(adapter2, SimpleNamespace(mentions=[bot_mention]), sender_id, \"\")\n+        )\n \n     @patch.dict(os.environ, {}, clear=True)\n     def test_extract_post_message_as_text(self):","@@ -1411,6 +1451,7 @@ def test_extract_text_message_starting_with_slash_becomes_command(self):\n                 data=SimpleNamespace(event=SimpleNamespace(message=message)),\n                 message=message,\n                 sender_id=SimpleNamespace(open_id=\"ou_user\", user_id=None, union_id=None),\n+                is_bot=False,\n                 chat_type=\"p2p\",\n                 message_id=\"om_command\",\n             )","@@ -1522,13 +1563,14 @@ def test_process_inbound_message_uses_event_sender_identity_only(self):\n             user_id=\"u_user\",\n             union_id=\"on_union\",\n         )\n-        data = SimpleNamespace(event=SimpleNamespace(message=message, sender=SimpleNamespace(sender_id=sender_id)))\n+        sender = SimpleNamespace(sender_type=\"user\", sender_id=sender_id)\n+        data = SimpleNamespace(event=SimpleNamespace(message=message, sender=sender))\n \n         asyncio.run(\n             adapter._process_inbound_message(\n                 data=data,\n                 message=message,\n-                sender_id=sender_id,\n+                sender_id=sender.sender_id,\n                 chat_type=\"p2p\",\n                 message_id=\"om_text\",\n             )","@@ -1761,13 +1803,14 @@ def test_process_inbound_group_message_keeps_group_type_when_chat_lookup_falls_b\n             message_id=\"om_group_text\",\n         )\n         sender_id = SimpleNamespace(open_id=\"ou_user\", user_id=None, union_id=None)\n+        sender = SimpleNamespace(sender_type=\"user\", sender_id=sender_id)\n         data = SimpleNamespace(event=SimpleNamespace(message=message))\n \n         asyncio.run(\n             adapter._process_inbound_message(\n                 data=data,\n                 message=message,\n-                sender_id=sender_id,\n+                sender_id=sender.sender_id,\n                 chat_type=\"group\",\n                 message_id=\"om_group_text\",\n             )","@@ -1805,6 +1848,7 @@ def test_process_inbound_message_fetches_reply_to_text(self):\n                 data=SimpleNamespace(event=SimpleNamespace(message=message)),\n                 message=message,\n                 sender_id=SimpleNamespace(open_id=\"ou_user\", user_id=None, union_id=None),\n+                is_bot=False,\n                 chat_type=\"p2p\",\n                 message_id=\"om_reply\",\n             )","@@ -2667,11 +2711,12 @@ async def _direct(func, *args, **kwargs):\n \n @unittest.skipUnless(_HAS_LARK_OAPI, \"lark-oapi not installed\")\n class TestHydrateBotIdentity(unittest.TestCase):\n-    \"\"\"Hydration of bot identity via /open-apis/bot/v3/info and application info.\n+    \"\"\"Hydration of bot identity via ``/open-apis/bot/v3/info``.\n \n-    Covers the manual-setup path where FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID\n-    are not configured. Hydration must populate _bot_open_id so that\n-    _is_self_sent_bot_message() can filter the adapter's own outbound echoes.\n+    Covers the manual-setup path where ``FEISHU_BOT_OPEN_ID`` /\n+    ``FEISHU_BOT_NAME`` are not configured — hydration populates them so\n+    self-echo protection and group @mention gating both have something to\n+    match against.\n     \"\"\"\n \n     def _make_adapter(self):","@@ -2700,11 +2745,6 @@ def test_hydration_populates_open_id_from_bot_info(self):\n \n         self.assertEqual(adapter._bot_open_id, \"ou_hermes_hydrated\")\n         self.assertEqual(adapter._bot_name, \"Hermes Bot\")\n-        # Application-info fallback must NOT run when bot_name is already set.\n-        self.assertFalse(\n-            adapter._client.application.v6.application.get.called\n-            if hasattr(adapter._client, \"application\") else False\n-        )\n \n     @patch.dict(\n         os.environ,","@@ -2721,7 +2761,6 @@ def test_hydration_skipped_when_env_vars_supply_both_fields(self):\n \n         asyncio.run(adapter._hydrate_bot_identity())\n \n-        # Neither probe should run — both fields are already populated.\n         adapter._client.request.assert_not_called()\n         self.assertEqual(adapter._bot_open_id, \"ou_env\")\n         self.assertEqual(adapter._bot_name, \"Env Hermes\")","@@ -2766,33 +2805,6 @@ def test_hydration_tolerates_probe_failure_and_falls_back_to_app_info(self):\n         self.assertEqual(adapter._bot_open_id, \"\")\n         self.assertEqual(adapter._bot_name, \"Fallback Bot\")\n \n-    @patch.dict(os.environ, {}, clear=True)\n-    def test_hydrated_open_id_enables_self_send_filter(self):\n-        \"\"\"E2E: after hydration, _is_self_sent_bot_message() rejects adapter's own id.\"\"\"\n-        adapter = self._make_adapter()\n-        adapter._client = Mock()\n-        payload = json.dumps(\n-            {\"code\": 0, \"bot\": {\"bot_name\": \"Hermes\", \"open_id\": \"ou_hermes\"}}\n-        ).encode(\"utf-8\")\n-        adapter._client.request = Mock(return_value=SimpleNamespace(raw=SimpleNamespace(content=payload)))\n-\n-        asyncio.run(adapter._hydrate_bot_identity())\n-\n-        self_event = SimpleNamespace(\n-            sender=SimpleNamespace(\n-                sender_type=\"bot\",\n-                sender_id=SimpleNamespace(open_id=\"ou_hermes\", user_id=\"\"),\n-            )\n-        )\n-        peer_event = SimpleNamespace(\n-            sender=SimpleNamespace(\n-                sender_type=\"bot\",\n-                sender_id=SimpleNamespace(open_id=\"ou_peer_bot\", user_id=\"\"),\n-            )\n-        )\n-        self.assertTrue(adapter._is_self_sent_bot_message(self_event))\n-        self.assertFalse(adapter._is_self_sent_bot_message(peer_event))\n-\n \n @unittest.skipUnless(_HAS_LARK_OAPI, \"lark-oapi not installed\")\n class TestPendingInboundQueue(unittest.TestCase):","@@ -3137,7 +3149,7 @@ def test_at_all_in_content_accepts_without_explicit_bot_mention(self):\n             mentions=[],\n         )\n         sender_id = SimpleNamespace(open_id=\"ou_any\", user_id=None)\n-        self.assertTrue(adapter._should_accept_group_message(message, sender_id, \"\"))\n+        self.assertTrue(_admits_group(adapter, message, sender_id, \"\"))\n \n     @patch.dict(os.environ, {\"FEISHU_GROUP_POLICY\": \"allowlist\", \"FEISHU_ALLOWED_USERS\": \"ou_allowed\"}, clear=True)\n     def test_at_all_still_requires_policy_gate(self):","@@ -3149,15 +3161,15 @@ def test_at_all_still_requires_policy_gate(self):\n         message = SimpleNamespace(content='{\"text\":\"@_all attention\"}', mentions=[])\n         # Non-allowlisted user — should be blocked even with @_all.\n         blocked_sender = SimpleNamespace(open_id=\"ou_blocked\", user_id=None)\n-        self.assertFalse(adapter._should_accept_group_message(message, blocked_sender, \"\"))\n+        self.assertFalse(_admits_group(adapter, message, blocked_sender, \"\"))\n         # Allowlisted user — should pass.\n         allowed_sender = SimpleNamespace(open_id=\"ou_allowed\", user_id=None)\n-        self.assertTrue(adapter._should_accept_group_message(message, allowed_sender, \"\"))\n+        self.assertTrue(_admits_group(adapter, message, allowed_sender, \"\"))\n \n \n @unittest.skipUnless(_HAS_LARK_OAPI, \"lark-oapi not installed\")\n class TestSenderNameResolution(unittest.TestCase):\n-    \"\"\"Tests for _resolve_sender_name_from_api.\"\"\"\n+    \"\"\"Tests for _resolve_sender_name_from_api (contact API + cache).\"\"\"\n \n     @patch.dict(os.environ, {}, clear=True)\n     def test_returns_none_when_client_is_none(self):","@@ -3261,6 +3273,137 @@ async def _direct(func, *args, **kwargs):\n         self.assertIsNone(result)\n \n \n[redacted:email](_HAS_LARK_OAPI, \"lark-oapi not installed\")\n+class TestBotNameResolution(unittest.TestCase):\n+    \"\"\"Tests for the bot branch of _resolve_sender_name_from_api (basic_batch API + shared cache).\"\"\"\n+\n+    @staticmethod\n+    def _batch_payload(bots: Dict[str, str]):\n+        import json as _json\n+        body = {\n+            oid: {\"bot_id\": oid, \"name\": name, \"i18n_names\": {\"en_us\": name}}\n+            for oid, name in bots.items()\n+        }\n+        return _json.dumps({\"code\": 0, \"msg\": \"\", \"data\": {\"bots\": body, \"failed_bots\": {}}}).encode()\n+\n+    def _build_adapter_with_bots(self, bots: Dict[str, str]):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        calls = []\n+\n+        def _fake_request(request):\n+            calls.append(request)\n+            return SimpleNamespace(raw=SimpleNamespace(content=self._batch_payload(bots)))\n+\n+        adapter._client = SimpleNamespace(request=_fake_request)\n+        return adapter, calls\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_returns_cached_bot_name_without_api_call(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._sender_name_cache[\"ou_peer\"] = (\"Peer Bot\", time.time() + 600)\n+        adapter._client = SimpleNamespace(\n+            request=lambda _r: (_ for _ in ()).throw(RuntimeError(\"should not fetch\"))\n+        )\n+        result = asyncio.run(adapter._resolve_sender_name_from_api(\"ou_peer\", is_bot=True))\n+        self.assertEqual(result, \"Peer Bot\")\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_fetches_and_caches_bot_name(self):\n+        adapter, calls = self._build_adapter_with_bots({\"ou_peer\": \"Peer Bot\"})\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+            result = asyncio.run(adapter._resolve_sender_name_from_api(\"ou_peer\", is_bot=True))\n+\n+        self.assertEqual(result, \"Peer Bot\")\n+        self.assertEqual(adapter._sender_name_cache[\"ou_peer\"][0], \"Peer Bot\")\n+        self.assertEqual(len(calls), 1)\n+        self.assertIn(\"/open-apis/bot/v3/bots/basic_batch\", calls[0].uri)\n+        # Feishu expects repeated ?bot_ids= params, not comma-joined.\n+        self.assertEqual(calls[0].queries, [(\"bot_ids\", \"ou_peer\")])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_api_failure_returns_none_and_does_not_poison_cache(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+\n+        def _broken_request(_req):\n+            raise RuntimeError(\"API down\")\n+\n+        adapter._client = SimpleNamespace(request=_broken_request)\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+            result = asyncio.run(adapter._resolve_sender_name_from_api(\"ou_peer\", is_bot=True))\n+\n+        self.assertIsNone(result)\n+        self.assertNotIn(\"ou_peer\", adapter._sender_name_cache)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_bot_absent_from_response_is_not_cached(self):\n+        \"\"\"Bot not in ``data.bots`` (e.g. landed in ``failed_bots``) → no\n+        cache entry, next lookup re-fetches.\"\"\"\n+        adapter, _ = self._build_adapter_with_bots({\"ou_other\": \"Other Bot\"})\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+            result = asyncio.run(adapter._resolve_sender_name_from_api(\"ou_ghost\", is_bot=True))\n+\n+        self.assertIsNone(result)\n+        self.assertNotIn(\"ou_ghost\", adapter._sender_name_cache)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_empty_name_in_response_is_negative_cached(self):\n+        \"\"\"API returns name=\"\" → cache \"\" so repeat lookups short-circuit.\"\"\"\n+        adapter, calls = self._build_adapter_with_bots({\"ou_nameless\": \"\"})\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+            first = asyncio.run(adapter._resolve_sender_name_from_api(\"ou_nameless\", is_bot=True))\n+            second = asyncio.run(adapter._resolve_sender_name_from_api(\"ou_nameless\", is_bot=True))\n+\n+        self.assertIsNone(first)\n+        self.assertIsNone(second)\n+        self.assertEqual(adapter._sender_name_cache[\"ou_nameless\"][0], \"\")\n+        self.assertEqual(len(calls), 1)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_non_zero_code_returns_none(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        error_payload = b'{\"code\":99991663,\"msg\":\"permission denied\"}'\n+        adapter._client = SimpleNamespace(\n+            request=lambda _r: SimpleNamespace(raw=SimpleNamespace(content=error_payload))\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+            result = asyncio.run(adapter._resolve_sender_name_from_api(\"ou_peer\", is_bot=True))\n+\n+        self.assertIsNone(result)\n+        self.assertNotIn(\"ou_peer\", adapter._sender_name_cache)\n+\n+\n @unittest.skipUnless(_HAS_LARK_OAPI, \"lark-oapi not installed\")\n class TestProcessingReactions(unittest.TestCase):\n     \"\"\"Typing on start → removed on SUCCESS, swapped for CrossMark on FAILURE,"]},{"file":"tests/gateway/test_feishu_bot_admission.py","hunks":["@@ -0,0 +1,745 @@\n+\"\"\"Adapter-layer tests for Feishu bot-sender admission (``FeishuAdapter._admit``).\"\"\"\n+\n+from __future__ import annotations\n+\n+from types import SimpleNamespace\n+from typing import Any\n+\n+import pytest\n+\n+from tests.gateway.feishu_helpers import (\n+    install_dedup_state,\n+    make_adapter_skeleton,\n+    make_message,\n+    make_sender,\n+    stub_mention,\n+)\n+\n+\n+# --- FeishuAdapterSettings wiring ------------------------------------------\n+\n+\n[redacted:email](\n+    \"env_value, expected\",\n+    [\n+        (\"none\", \"none\"),\n+        (\"mentions\", \"mentions\"),\n+        (\"all\", \"all\"),\n+        (\"  Mentions  \", \"mentions\"),\n+    ],\n+)\n+def test_feishu_load_settings_populates_allow_bots(monkeypatch, env_value, expected):\n+    from gateway.platforms.feishu import FeishuAdapter\n+\n+    monkeypatch.setenv(\"FEISHU_APP_ID\", \"cli_test\")\n+    monkeypatch.setenv(\"FEISHU_APP_SECRET\", \"secret_test\")\n+    monkeypatch.setenv(\"FEISHU_ALLOW_BOTS\", env_value)\n+\n+    settings = FeishuAdapter._load_settings(extra={})\n+    assert settings.allow_bots == expected\n+\n+\n+def test_feishu_load_settings_allow_bots_defaults_to_none(monkeypatch):\n+    from gateway.platforms.feishu import FeishuAdapter\n+\n+    monkeypatch.setenv(\"FEISHU_APP_ID\", \"cli_test\")\n+    monkeypatch.setenv(\"FEISHU_APP_SECRET\", \"secret_test\")\n+    monkeypatch.delenv(\"FEISHU_ALLOW_BOTS\", raising=False)\n+\n+    settings = FeishuAdapter._load_settings(extra={})\n+    assert settings.allow_bots == \"none\"\n+\n+\n+def test_feishu_load_settings_ignores_extra_allow_bots(monkeypatch):\n+    # extra is ignored — env is single source of truth (yaml is bridged to env).\n+    from gateway.platforms.feishu import FeishuAdapter\n+\n+    monkeypatch.setenv(\"FEISHU_APP_ID\", \"cli_test\")\n+    monkeypatch.setenv(\"FEISHU_APP_SECRET\", \"secret_test\")\n+    monkeypatch.delenv(\"FEISHU_ALLOW_BOTS\", raising=False)\n+\n+    settings = FeishuAdapter._load_settings(extra={\"allow_bots\": \"all\"})\n+    assert settings.allow_bots == \"none\"\n+\n+\n+def test_feishu_load_settings_falls_back_to_env_when_extra_missing(monkeypatch):\n+    from gateway.platforms.feishu import FeishuAdapter\n+\n+    monkeypatch.setenv(\"FEISHU_APP_ID\", \"cli_test\")\n+    monkeypatch.setenv(\"FEISHU_APP_SECRET\", \"secret_test\")\n+    monkeypatch.setenv(\"FEISHU_ALLOW_BOTS\", \"mentions\")\n+\n+    settings = FeishuAdapter._load_settings(extra={})\n+    assert settings.allow_bots == \"mentions\"\n+\n+\n+def test_feishu_load_settings_warns_on_unknown_allow_bots(monkeypatch, caplog):\n+    import logging\n+\n+    from gateway.platforms.feishu import FeishuAdapter\n+\n+    monkeypatch.setenv(\"FEISHU_APP_ID\", \"cli_test\")\n+    monkeypatch.setenv(\"FEISHU_APP_SECRET\", \"secret_test\")\n+    monkeypatch.setenv(\"FEISHU_ALLOW_BOTS\", \"menton\")  # typo\n+\n+    with caplog.at_level(logging.WARNING, logger=\"gateway.platforms.feishu\"):\n+        settings = FeishuAdapter._load_settings(extra={})\n+\n+    assert settings.allow_bots == \"none\"\n+    assert any(\"allow_bots\" in r.message and \"menton\" in r.message for r in caplog.records)\n+\n+\n[redacted:email](\n+    \"env_value, extra, expected\",\n+    [\n+        (None, {}, True),\n+        (\"false\", {}, False),\n+        (\"true\", {}, True),\n+        (\"true\", {\"require_mention\": False}, False),\n+    ],\n+)\n+def test_feishu_load_settings_require_mention(monkeypatch, env_value, extra, expected):\n+    from gateway.platforms.feishu import FeishuAdapter\n+\n+    monkeypatch.setenv(\"FEISHU_APP_ID\", \"cli_test\")\n+    monkeypatch.setenv(\"FEISHU_APP_SECRET\", \"secret_test\")\n+    if env_value is None:\n+        monkeypatch.delenv(\"FEISHU_REQUIRE_MENTION\", raising=False)\n+    else:\n+        monkeypatch.setenv(\"FEISHU_REQUIRE_MENTION\", env_value)\n+\n+    settings = FeishuAdapter._load_settings(extra=extra)\n+    assert settings.require_mention is expected\n+\n+\n+def test_feishu_load_settings_parses_per_group_require_mention(monkeypatch):\n+    from gateway.platforms.feishu import FeishuAdapter\n+\n+    monkeypatch.setenv(\"FEISHU_APP_ID\", \"cli_test\")\n+    monkeypatch.setenv(\"FEISHU_APP_SECRET\", \"secret_test\")\n+\n+    settings = FeishuAdapter._load_settings(extra={\n+        \"group_rules\": {\n+            \"oc_free\": {\"policy\": \"open\", \"require_mention\": False},\n+            \"oc_strict\": {\"policy\": \"open\", \"require_mention\": True},\n+            \"oc_inherit\": {\"policy\": \"open\"},\n+        },\n+    })\n+    assert settings.group_rules[\"oc_free\"].require_mention is False\n+    assert settings.group_rules[\"oc_strict\"].require_mention is True\n+    assert settings.group_rules[\"oc_inherit\"].require_mention is None\n+\n+\n+# --- Module-level helpers --------------------------------------------------\n+\n+\n+def test_sender_identity_collects_every_non_empty_id_variant():\n+    from gateway.platforms.feishu import _sender_identity\n+\n+    sender = SimpleNamespace(\n+        sender_id=SimpleNamespace(open_id=\"ou_x\", user_id=\"\", union_id=\"un_x\"),\n+    )\n+    assert _sender_identity(sender) == frozenset({\"ou_x\", \"un_x\"})\n+\n+\n+def test_sender_identity_handles_missing_sender_id():\n+    from gateway.platforms.feishu import _sender_identity\n+\n+    assert _sender_identity(SimpleNamespace()) == frozenset()\n+\n+\n[redacted:email](\"sender_type\", [\"bot\", \"app\"])\n+def test_is_bot_sender_treats_bot_and_app_as_bot_origin(sender_type):\n+    from gateway.platforms.feishu import _is_bot_sender\n+\n+    assert _is_bot_sender(SimpleNamespace(sender_type=sender_type)) is True\n+\n+\n[redacted:email](\"sender_type\", [\"user\", \"\", None])\n+def test_is_bot_sender_rejects_non_bot_origin(sender_type):\n+    from gateway.platforms.feishu import _is_bot_sender\n+\n+    assert _is_bot_sender(SimpleNamespace(sender_type=sender_type)) is False\n+\n+\n+# --- _admit pipeline matrix ------------------------------------------------\n+#\n+# Covers the four-step admission pipeline (self_echo → bot_policy →\n+# DM bypass → group_policy + mention) as a single result-only matrix.\n+# Each row pins one decision in the pipeline; tests asserting call-count\n+# semantics live below in their own functions.\n+\n+\n+def _admit_case(\n+    *,\n+    adapter: dict | None = None,\n+    sender: dict | None = None,\n+    message: dict | None = None,\n+    mentions_self: bool | None = None,\n+    expected: str | None = None,\n+):\n+    return {\n+        \"adapter\": adapter or {},\n+        \"sender\": sender or {},\n+        \"message\": message or {},\n+        \"mentions_self\": mentions_self,\n+        \"expected\": expected,\n+    }\n+\n+\n+_ADMIT_CASES = [\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"ou_me\", \"allow_bots\": \"all\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_me\"},\n+            expected=\"self_echo\",\n+        ),\n+        id=\"self_echo:open_id_under_all_mode\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"\", \"bot_user_id\": \"u_me\", \"allow_bots\": \"all\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": None, \"user_id\": \"u_me\"},\n+            expected=\"self_echo\",\n+        ),\n+        id=\"self_echo:user_id_only\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"ou_me\", \"allow_bots\": \"all\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_me\", \"user_id\": \"u_me\", \"union_id\": \"un_me\"},\n+            expected=\"self_echo\",\n+        ),\n+        id=\"self_echo:mixed_ids\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"ou_self\", \"bot_user_id\": \"u_self\", \"allow_bots\": \"all\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": None, \"user_id\": \"u_self\"},\n+            expected=\"self_echo\",\n+        ),\n+        id=\"self_echo:user_id_when_bot_user_id_set\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"ou_self\", \"allow_bots\": \"none\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            expected=\"bots_disabled\",\n+        ),\n+        id=\"bots_disabled:mode_none\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"ou_self\", \"allow_bots\": \"\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            expected=\"bots_disabled\",\n+        ),\n+        id=\"bots_disabled:mode_empty\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"ou_self\", \"allow_bots\": \"loose\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            expected=\"bots_disabled\",\n+        ),\n+        id=\"bots_disabled:mode_unknown_value\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"\", \"allow_bots\": \"none\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            expected=\"bots_disabled\",\n+        ),\n+        id=\"bots_disabled:wins_over_self_ids_unknown\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"\", \"allow_bots\": \"all\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            expected=\"self_ids_unknown\",\n+        ),\n+        id=\"self_ids_unknown:bot_sender_no_self_ids\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"\", \"allow_bots\": \"all\"},\n+            sender={\"sender_type\": \"app\", \"open_id\": \"ou_peer\"},\n+            expected=\"self_ids_unknown\",\n+        ),\n+        id=\"self_ids_unknown:app_sender_no_self_ids\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"ou_self\", \"allow_bots\": \"all\"},\n+            sender={\"sender_type\": \"app\", \"open_id\": None},\n+            expected=\"self_ids_unknown\",\n+        ),\n+        id=\"self_ids_unknown:no_sender_ids\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"ou_self\", \"allow_bots\": \"mentions\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            mentions_self=False,\n+            expected=\"bot_not_mentioned\",\n+        ),\n+        id=\"mentions_mode:not_mentioned_dm\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"ou_self\", \"allow_bots\": \"mentions\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            mentions_self=True,\n+            expected=None,\n+        ),\n+        id=\"mentions_mode:mentioned_dm\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"ou_self\", \"allow_bots\": \"all\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            mentions_self=False,\n+            expected=None,\n+        ),\n+        id=\"all_mode:not_mentioned_dm\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"ou_self\", \"allow_bots\": \"all\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            mentions_self=True,\n+            expected=None,\n+        ),\n+        id=\"all_mode:mentioned_dm\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"bot_open_id\": \"\", \"allow_bots\": \"none\"},\n+            sender={\"sender_type\": \"user\", \"open_id\": \"ou_human\"},\n+            expected=None,\n+        ),\n+        id=\"human:dm_admitted_regardless_of_allow_bots\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\"allow_bots\": \"all\"},\n+            sender={\"sender_type\": \"user\", \"open_id\": \"ou_human\"},\n+            message={\"message_id\": \"om_ok\", \"chat_type\": \"p2p\"},\n+            expected=None,\n+        ),\n+        id=\"human:p2p_admitted\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\n+                \"bot_open_id\": \"ou_self\",\n+                \"require_mention\": False,\n+                \"group_policy\": \"open\",\n+            },\n+            sender={\"sender_type\": \"user\", \"open_id\": \"ou_human\"},\n+            message={\"chat_type\": \"group\"},\n+            mentions_self=False,\n+            expected=None,\n+        ),\n+        id=\"require_mention_false:group_human_no_mention_admitted\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\n+                \"bot_open_id\": \"ou_self\",\n+                \"allow_bots\": \"all\",\n+                \"require_mention\": False,\n+                \"group_policy\": \"open\",\n+            },\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            message={\"chat_type\": \"group\"},\n+            mentions_self=False,\n+            expected=None,\n+        ),\n+        id=\"require_mention_false:group_bot_all_mode_admitted\",\n+    ),\n+    pytest.param(\n+        _admit_case(\n+            adapter={\n+                \"bot_open_id\": \"ou_self\",\n+                \"allow_bots\": \"mentions\",\n+                \"require_mention\": False,\n+                \"group_policy\": \"open\",\n+            },\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            message={\"chat_type\": \"group\"},\n+            mentions_self=False,\n+            expected=\"bot_not_mentioned\",\n+        ),\n+        id=\"require_mention_false:group_bot_mentions_mode_still_gated\",\n+    ),\n+]\n+\n+\n[redacted:email](\"case\", _ADMIT_CASES)\n+def test_admit_pipeline(case):\n+    adapter = make_adapter_skeleton(**case[\"adapter\"])\n+    if case[\"mentions_self\"] is not None:\n+        stub_mention(adapter, case[\"mentions_self\"])\n+    sender = make_sender(**case[\"sender\"])\n+    message = make_message(**case[\"message\"])\n+    assert adapter._admit(sender, message) == case[\"expected\"]\n+\n+\n+# --- Mention call-count semantics ------------------------------------------\n+\n+\n+def test_admit_skips_mention_check_under_all_mode():\n+    # Tripwire: under allow_bots=all the mention path must not be probed.\n+    adapter = make_adapter_skeleton(bot_open_id=\"ou_self\", allow_bots=\"all\")\n+    calls = 0\n+\n+    def _tripwire(_message):\n+        nonlocal calls\n+        calls += 1\n+        return False\n+\n+    adapter._mentions_self = _tripwire\n+\n+    sender = make_sender(sender_type=\"bot\", open_id=\"ou_peer\")\n+    assert adapter._admit(sender, make_message()) is None\n+    assert calls == 0\n+\n+\n+def test_admit_group_mention_checked_once_per_call():\n+    # Stage 2 (mentions mode) and stage 4 (group require_mention) must not\n+    # double-evaluate _mentions_self for the same admit call.\n+    adapter = make_adapter_skeleton(\n+        bot_open_id=\"ou_self\", allow_bots=\"mentions\", require_mention=True,\n+        group_policy=\"open\",\n+    )\n+    calls = 0\n+\n+    def _counting(_message):\n+        nonlocal calls\n+        calls += 1\n+        return True\n+\n+    adapter._mentions_self = _counting\n+\n+    sender = make_sender(sender_type=\"bot\", open_id=\"ou_peer\")\n+    assert adapter._admit(sender, make_message(chat_type=\"group\")) is None\n+    assert calls == 1\n+\n+\n+# --- Per-group require_mention override ------------------------------------\n+\n+\n+def test_admit_per_group_require_mention_overrides_global():\n+    from gateway.platforms.feishu import FeishuGroupRule\n+\n+    adapter = make_adapter_skeleton(\n+        bot_open_id=\"ou_self\", require_mention=True, group_policy=\"open\",\n+    )\n+    adapter._group_rules = {\n+        \"oc_free\": FeishuGroupRule(policy=\"open\", require_mention=False),\n+    }\n+    stub_mention(adapter, False)\n+\n+    sender = make_sender(sender_type=\"user\", open_id=\"ou_human\")\n+    assert adapter._admit(sender, make_message(chat_id=\"oc_free\", chat_type=\"group\")) is None\n+    assert (\n+        adapter._admit(sender, make_message(chat_id=\"oc_other\", chat_type=\"group\"))\n+        == \"group_policy_rejected\"\n+    )\n+\n+\n+# --- Hydration -------------------------------------------------------------\n+\n+\n+def test_hydrate_bot_identity_populates_self_ids_from_bot_v3_info(monkeypatch):\n+    import asyncio\n+\n+    from gateway.platforms.feishu import FeishuAdapter\n+\n+    adapter = object.__new__(FeishuAdapter)\n+    adapter._bot_open_id = \"\"\n+    adapter._bot_user_id = \"\"\n+    adapter._bot_name = \"\"\n+    adapter._allow_bots = \"all\"\n+\n+    captured = {}\n+\n+    def _fake_request(request):\n+        captured[\"uri\"] = getattr(request, \"uri\", None)\n+        captured[\"http_method\"] = getattr(request, \"http_method\", None)\n+        return SimpleNamespace(raw=SimpleNamespace(\n+            content=b'{\"code\":0,\"bot\":{\"app_name\":\"Hermes\",\"open_id\":\"ou_hydrated\"}}'\n+        ))\n+\n+    adapter._client = SimpleNamespace(request=_fake_request)\n+\n+    asyncio.run(adapter._hydrate_bot_identity())\n+\n+    assert captured[\"uri\"] == \"/open-apis/bot/v3/info\"\n+    assert str(captured[\"http_method\"]).endswith(\"GET\")\n+    assert adapter._bot_open_id == \"ou_hydrated\"\n+    assert adapter._bot_name == \"Hermes\"\n+    # /bot/v3/info doesn't surface user_id, so _bot_user_id stays empty.\n+    assert adapter._bot_user_id == \"\"\n+\n+\n+def test_resolve_sender_profile_uses_open_id_for_bot_name_lookup():\n+    import asyncio\n+\n+    from gateway.platforms.feishu import FeishuAdapter\n+\n+    adapter = object.__new__(FeishuAdapter)\n+    adapter._client = object()\n+    adapter._sender_name_cache = {}\n+    seen_ids = []\n+\n+    async def _fake_fetch_bot_names(bot_ids):\n+        seen_ids.extend(bot_ids)\n+        return {\"ou_peer\": \"Peer Bot\"}\n+\n+    adapter._fetch_bot_names = _fake_fetch_bot_names\n+\n+    profile = asyncio.run(\n+        adapter._resolve_sender_profile(\n+            SimpleNamespace(open_id=\"ou_peer\", user_id=\"u_peer\", union_id=\"on_peer\"),\n+            is_bot=True,\n+        )\n+    )\n+\n+    assert seen_ids == [\"ou_peer\"]\n+    assert profile[\"user_id\"] == \"u_peer\"\n+    assert profile[\"user_name\"] == \"Peer Bot\"\n+\n+\n+# --- _allow_group_message matrix -------------------------------------------\n+#\n+# Bot-bypass semantics: admitted bots skip allowlist/blacklist (parallel\n+# human-scope filters), but channel-level locks (disabled, admin_only) and\n+# admin short-circuits still apply.\n+\n+\n+def _group_case(\n+    *,\n+    adapter: dict | None = None,\n+    admins: set | None = None,\n+    group_rules: dict | None = None,\n+    sender: dict | None = None,\n+    chat_id: str = \"oc_1\",\n+    is_bot: bool = False,\n+    expected: bool = False,\n+):\n+    return {\n+        \"adapter\": adapter or {},\n+        \"admins\": admins or set(),\n+        \"group_rules\": group_rules or {},\n+        \"sender\": sender or {},\n+        \"chat_id\": chat_id,\n+        \"is_bot\": is_bot,\n+        \"expected\": expected,\n+    }\n+\n+\n+def _group_rule(policy: str, **kwargs):\n+    from gateway.platforms.feishu import FeishuGroupRule\n+    return FeishuGroupRule(policy=policy, **kwargs)\n+\n+\n+_GROUP_CASES = [\n+    pytest.param(\n+        _group_case(\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            is_bot=True,\n+            expected=True,\n+        ),\n+        id=\"bot:bypasses_default_allowlist\",\n+    ),\n+    pytest.param(\n+        _group_case(\n+            sender={\"sender_type\": \"user\", \"open_id\": \"ou_stranger\"},\n+            is_bot=False,\n+            expected=False,\n+        ),\n+        id=\"human:gated_by_default_allowlist\",\n+    ),\n+    pytest.param(\n+        _group_case(\n+            admins={\"ou_peer\"},\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            is_bot=True,\n+            expected=True,\n+        ),\n+        id=\"bot:admin_short_circuit\",\n+    ),\n+    pytest.param(\n+        _group_case(\n+            admins={\"u_admin\"},\n+            sender={\"sender_type\": \"user\", \"open_id\": None, \"user_id\": \"u_admin\"},\n+            is_bot=False,\n+            expected=True,\n+        ),\n+        id=\"human:admin_via_user_id\",\n+    ),\n+    pytest.param(\n+        _group_case(\n+            sender={\"sender_type\": \"bot\", \"open_id\": \"ou_peer\"},\n+            is_bot=True,\n+            expected=True,\n+        ),\n+        id=\"bot:allowlist_skipped\",\n+    ),\n+    pytest.param(\n+        _group_case(\n+            sender={\"sender_type\": \"app\", \"open_id\": \"ou_peer\"},\n+            is_bot=True,\n+            expected=True,\n+        ),\n+        id=\"app:allowlist_skipped\",\n+    ),\n+]\n+\n+\n+# Channel-lock cases need group_rules construction; keep them in a separate\n+# parametrize so we can use _group_rule() (FeishuGroupRule import).\n+_GROUP_RULE_CASES = [\n+    pytest.param(\n+        \"disabled\", \"bot\", False,\n+        id=\"bot:disabled_policy_blocks_even_with_bypass\",\n+    ),\n+    pytest.param(\n+        \"disabled\", \"app\", False,\n+        id=\"app:disabled_policy_blocks_even_with_bypass\",\n+    ),\n+    pytest.param(\n+        \"admin_only\", \"bot\", False,\n+        id=\"bot:admin_only_policy_blocks_non_admin\",\n+    ),\n+    pytest.param(\n+        \"admin_only\", \"app\", False,\n+        id=\"app:admin_only_policy_blocks_non_admin\",\n+    ),\n+]\n+\n+\n[redacted:email](\"case\", _GROUP_CASES)\n+def test_allow_group_message_matrix(case):\n+    adapter = make_adapter_skeleton(**case[\"adapter\"])\n+    adapter._admins = case[\"admins\"]\n+    adapter._group_rules = case[\"group_rules\"]\n+    sender = make_sender(**case[\"sender\"])\n+    assert adapter._allow_group_message(\n+        sender_id=sender.sender_id,\n+        chat_id=case[\"chat_id\"],\n+        is_bot=case[\"is_bot\"],\n+    ) is case[\"expected\"]\n+\n+\n[redacted:email](\"policy, sender_type, expected\", _GROUP_RULE_CASES)\n+def test_allow_group_message_channel_locks_apply_to_bots(policy, sender_type, expected):\n+    adapter = make_adapter_skeleton()\n+    adapter._group_rules = {\"oc_locked\": _group_rule(policy)}\n+    sender = make_sender(sender_type=sender_type, open_id=\"ou_peer\")\n+    assert adapter._allow_group_message(\n+        sender_id=sender.sender_id,\n+        chat_id=\"oc_locked\",\n+        is_bot=True,\n+    ) is expected\n+\n+\n[redacted:email](\"sender_type\", [\"bot\", \"app\"])\n+def test_allow_group_message_blacklist_is_human_scope_only(sender_type):\n+    # blacklist is parallel to allowlist (human-scope); admitted bots bypass\n+    # it. To block a specific bot, gate upstream via FEISHU_ALLOW_BOTS.\n+    adapter = make_adapter_skeleton()\n+    adapter._group_rules = {\n+        \"oc_1\": _group_rule(\"blacklist\", blacklist={\"ou_peer\"})\n+    }\n+    sender = make_sender(sender_type=sender_type, open_id=\"ou_peer\")\n+    assert adapter._allow_group_message(\n+        sender_id=sender.sender_id,\n+        chat_id=\"oc_1\",\n+        is_bot=True,\n+    ) is True\n+\n+\n+# --- Realistic payload smoke -----------------------------------------------\n+\n+\n+def test_admit_accepts_realistic_bot_at_bot_group_event():\n+    # Locks in the real im.message.receive_v1 payload shape under mode=mentions.\n+    adapter = make_adapter_skeleton(bot_open_id=\"ou_self\", allow_bots=\"mentions\")\n+\n+    mention = SimpleNamespace(\n+        key=\"@_user_1\",\n+        id=SimpleNamespace(union_id=\"on_mentionUnion\", user_id=\"\", open_id=\"ou_self\"),\n+        name=\"Hermes\",\n+        mentioned_type=\"bot\",\n+        tenant_key=\"tenant_ab\",\n+    )\n+    message = SimpleNamespace(\n+        message_id=\"om_realistic_bot_at_bot\",\n+        chat_id=\"oc_real\",\n+        chat_type=\"group\",\n+        message_type=\"text\",\n+        content='{\"text\":\"@_user_1 hello\"}',\n+        mentions=[mention],\n+    )\n+    sender = SimpleNamespace(\n+        sender_type=\"bot\",\n+        sender_id=SimpleNamespace(union_id=\"on_peerUnion\", user_id=\"u_peer\", open_id=\"ou_peer_bot\"),\n+        tenant_key=\"tenant_ab\",\n+    )\n+\n+    assert adapter._admit(sender, message) is None\n+\n+\n+# --- Event-dispatch plumbing -----------------------------------------------\n+\n+\n+def test_handle_message_event_data_drops_bot_sender_by_default():\n+    import asyncio\n+\n+    adapter = make_adapter_skeleton()\n+    install_dedup_state(adapter)\n+    processed = []\n+\n+    async def _fake_process_inbound_message(**kwargs):\n+        processed.append(kwargs)\n+\n+    adapter._process_inbound_message = _fake_process_inbound_message\n+\n+    data = SimpleNamespace(\n+        event=SimpleNamespace(\n+            sender=make_sender(sender_type=\"bot\", open_id=\"ou_peer\"),\n+            message=make_message(message_id=\"om_bot_default\", chat_type=\"p2p\"),\n+        )\n+    )\n+\n+    asyncio.run(adapter._handle_message_event_data(data))\n+    assert processed == []\n+\n+\n+def test_handle_message_event_data_forwards_sender_when_admitted():\n+    import asyncio\n+\n+    adapter = make_adapter_skeleton(allow_bots=\"all\")\n+    install_dedup_state(adapter)\n+    captured = {}\n+\n+    async def _fake_process_inbound_message(**kwargs):\n+        captured.update(kwargs)\n+\n+    adapter._process_inbound_message = _fake_process_inbound_message\n+\n+    sender = make_sender(sender_type=\"bot\", open_id=\"ou_peer\")\n+    data = SimpleNamespace(\n+        event=SimpleNamespace(\n+            sender=sender,\n+            message=make_message(message_id=\"om_bot_ok\", chat_type=\"p2p\"),\n+        )\n+    )\n+\n+    asyncio.run(adapter._handle_message_event_data(data))\n+    assert captured.get(\"sender_id\") is sender.sender_id\n+    assert captured.get(\"is_bot\") is True\n+    assert captured.get(\"message_id\") == \"om_bot_ok\""]},{"file":"tests/gateway/test_feishu_bot_auth_bypass.py","hunks":["@@ -0,0 +1,113 @@\n+\"\"\"Regression guard for Feishu bot-sender authorization bypass.\n+\n+Mirrors tests/gateway/test_discord_bot_auth_bypass.py for Platform.FEISHU.\n+Without the bypass in gateway/run.py, Feishu bot senders admitted by the\n+adapter would be rejected at _is_user_authorized with \"Unauthorized user\"\n+— same class of bug as Discord #4466.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from types import SimpleNamespace\n+\n+import pytest\n+\n+from gateway.session import Platform, SessionSource\n+\n+\n[redacted:email](autouse=True)\n+def _isolate_feishu_env(monkeypatch):\n+    for var in (\n+        \"FEISHU_ALLOW_BOTS\",\n+        \"FEISHU_ALLOWED_USERS\",\n+        \"FEISHU_ALLOW_ALL_USERS\",\n+        \"GATEWAY_ALLOW_ALL_USERS\",\n+        \"GATEWAY_ALLOWED_USERS\",\n+    ):\n+        monkeypatch.delenv(var, raising=False)\n+\n+\n+def _make_bare_runner():\n+    from gateway.run import GatewayRunner\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False)\n+    return runner\n+\n+\n+def _make_feishu_bot_source(open_id: str = \"ou_peer\"):\n+    return SessionSource(\n+        platform=Platform.FEISHU,\n+        chat_id=\"oc_1\",\n+        chat_type=\"group\",\n+        user_id=open_id,\n+        user_name=\"PeerBot\",\n+        is_bot=True,\n+    )\n+\n+\n+def _make_feishu_human_source(open_id: str = \"ou_human\"):\n+    return SessionSource(\n+        platform=Platform.FEISHU,\n+        chat_id=\"oc_1\",\n+        chat_type=\"group\",\n+        user_id=open_id,\n+        user_name=\"Human\",\n+        is_bot=False,\n+    )\n+\n+\n+def test_feishu_bot_authorized_when_allow_bots_mentions(monkeypatch):\n+    runner = _make_bare_runner()\n+    monkeypatch.setenv(\"FEISHU_ALLOW_BOTS\", \"mentions\")\n+    monkeypatch.setenv(\"FEISHU_ALLOWED_USERS\", \"ou_human\")\n+\n+    assert runner._is_user_authorized(_make_feishu_bot_source(\"ou_peer\")) is True\n+\n+\n+def test_feishu_bot_authorized_when_allow_bots_all(monkeypatch):\n+    runner = _make_bare_runner()\n+    monkeypatch.setenv(\"FEISHU_ALLOW_BOTS\", \"all\")\n+    monkeypatch.setenv(\"FEISHU_ALLOWED_USERS\", \"ou_human\")\n+\n+    assert runner._is_user_authorized(_make_feishu_bot_source()) is True\n+\n+\n+def test_feishu_bot_NOT_authorized_when_allow_bots_none(monkeypatch):\n+    runner = _make_bare_runner()\n+    monkeypatch.setenv(\"FEISHU_ALLOW_BOTS\", \"none\")\n+    monkeypatch.setenv(\"FEISHU_ALLOWED_USERS\", \"ou_human\")\n+\n+    assert runner._is_user_authorized(_make_feishu_bot_source(\"ou_peer\")) is False\n+\n+\n+def test_feishu_bot_NOT_authorized_when_allow_bots_unset(monkeypatch):\n+    runner = _make_bare_runner()\n+    monkeypatch.setenv(\"FEISHU_ALLOWED_USERS\", \"ou_human\")\n+\n+    assert runner._is_user_authorized(_make_feishu_bot_source(\"ou_peer\")) is False\n+\n+\n+def test_feishu_human_still_checked_against_allowlist_when_bot_policy_set(monkeypatch):\n+    \"\"\"FEISHU_ALLOW_BOTS=all must NOT open the gate for humans.\"\"\"\n+    runner = _make_bare_runner()\n+    monkeypatch.setenv(\"FEISHU_ALLOW_BOTS\", \"all\")\n+    monkeypatch.setenv(\"FEISHU_ALLOWED_USERS\", \"ou_human\")\n+\n+    assert runner._is_user_authorized(_make_feishu_human_source(\"ou_stranger\")) is False\n+    assert runner._is_user_authorized(_make_feishu_human_source(\"ou_human\")) is True\n+\n+\n+def test_feishu_bot_bypass_does_not_leak_to_other_platforms(monkeypatch):\n+    \"\"\"FEISHU_ALLOW_BOTS=all must not authorize Telegram/Discord bot sources.\"\"\"\n+    runner = _make_bare_runner()\n+    monkeypatch.setenv(\"FEISHU_ALLOW_BOTS\", \"all\")\n+\n+    telegram_bot = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"123\",\n+        chat_type=\"channel\",\n+        user_id=\"999\",\n+        is_bot=True,\n+    )\n+    assert runner._is_user_authorized(telegram_bot) is False"]},{"file":"website/docs/reference/environment-variables.md","hunks":["@@ -300,6 +300,8 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI\n | `FEISHU_ENCRYPT_KEY` | Optional encryption key for webhook mode |\n | `FEISHU_VERIFICATION_TOKEN` | Optional verification token for webhook mode |\n | `FEISHU_ALLOWED_USERS` | Comma-separated Feishu user IDs allowed to message the bot |\n+| `FEISHU_ALLOW_BOTS` | `none` (default) / `mentions` / `all` — accept inbound messages from other bots. See [bot-to-bot messaging](../user-guide/messaging/feishu.md#bot-to-bot-messaging) |\n+| `FEISHU_REQUIRE_MENTION` | `true` (default) / `false` — whether group messages must @mention the bot. Override per-chat via `group_rules.<chat_id>.require_mention`. |\n | `FEISHU_HOME_CHANNEL` | Feishu chat ID for cron delivery and notifications |\n | `WECOM_BOT_ID` | WeCom AI Bot ID from admin console |\n | `WECOM_SECRET` | WeCom AI Bot secret |"]},{"file":"website/docs/user-guide/messaging/feishu.md","hunks":["@@ -201,19 +201,45 @@ FEISHU_GROUP_POLICY=allowlist   # default\n | `allowlist` | Hermes only responds to @mentions from users listed in `FEISHU_ALLOWED_USERS`. |\n | `disabled` | Hermes ignores all group messages entirely. |\n \n-In all modes, the bot must be explicitly @mentioned (or @all) in the group before the message is processed. Direct messages bypass this gate.\n+In all modes, the bot must be explicitly @mentioned (or @all) in the group before the message is processed. Direct messages always bypass this gate.\n \n-### Bot Identity for @Mention Gating\n+Set `FEISHU_REQUIRE_MENTION=false` to let Hermes read all group traffic without requiring an @mention:\n \n-For precise @mention detection in groups, the adapter needs to know the bot's identity. It can be provided explicitly:\n+```bash\n+FEISHU_REQUIRE_MENTION=false\n+```\n+\n+For per-chat control, set `require_mention` on a `group_rules` entry — see [Per-Group Access Control](#per-group-access-control) below.\n+\n+### Bot Identity\n+\n+Hermes auto-detects the bot's `open_id` and display name on startup. You only need to set these manually when auto-detection cannot reach the Feishu API, or when your app uses tenant-scoped user IDs:\n+\n+```bash\n+FEISHU_BOT_OPEN_ID=ou_xxx     # only when auto-detection fails\n+FEISHU_BOT_USER_ID=xxx        # required if your app uses sender_id_type=user_id\n+FEISHU_BOT_NAME=MyBot         # only when auto-detection fails\n+```\n+\n+## Bot-to-Bot Messaging\n+\n+By default Hermes ignores messages sent by other bots. Enable bot-to-bot messaging when you want Hermes to participate in A2A orchestration or receive notifications from other bots in the same group.\n \n ```bash\n-FEISHU_BOT_OPEN_ID=ou_xxx\n-FEISHU_BOT_USER_ID=xxx\n-FEISHU_BOT_NAME=MyBot\n+FEISHU_ALLOW_BOTS=mentions   # default: none\n ```\n \n-If none of these are set, the adapter will attempt to auto-discover the bot name via the Application Info API on startup. For this to work, grant the `admin:app.info:readonly` or `application:application:self_manage` permission scope.\n+| Value | Behavior |\n+|-------|----------|\n+| `none` | Ignore all messages from other bots (default). |\n+| `mentions` | Accept only when the peer bot @mentions Hermes. |\n+| `all` | Accept every peer bot message. |\n+\n+Also configurable as `feishu.allow_bots` in `config.yaml` (env wins when both are set).\n+\n+Peer bots do not need to be added to `FEISHU_ALLOWED_USERS` — that allowlist applies to human senders only.\n+\n+Grant the `application:bot.basic_info:read` scope to display peer bot names; without it, peer bots still route correctly but appear as their `open_id`.\n \n ## Interactive Card Actions\n ","@@ -426,6 +452,9 @@ platforms:\n           policy: \"blacklist\"\n           blacklist:\n             - \"ou_blocked_user\"\n+        \"oc_free_chat\":\n+          policy: \"open\"\n+          require_mention: false       # overrides FEISHU_REQUIRE_MENTION for this chat\n ```\n \n | Policy | Description |","@@ -436,6 +465,8 @@ platforms:\n | `admin_only` | Only users in the global `admins` list can use the bot in this group |\n | `disabled` | Bot ignores all messages in this group |\n \n+Set `require_mention: false` on a `group_rules` entry to skip the @-mention requirement for that specific chat. When omitted, the chat inherits the global `FEISHU_REQUIRE_MENTION` value.\n+\n Groups not listed in `group_rules` fall back to `default_group_policy` (defaults to the value of `FEISHU_GROUP_POLICY`).\n \n ## Deduplication","@@ -455,6 +486,8 @@ Inbound messages are deduplicated using message IDs with a 24-hour TTL. The dedu\n | `FEISHU_DOMAIN` | — | `feishu` | `feishu` (China) or `lark` (international) |\n | `FEISHU_CONNECTION_MODE` | — | `websocket` | `websocket` or `webhook` |\n | `FEISHU_ALLOWED_USERS` | — | _(empty)_ | Comma-separated open_id list for user allowlist |\n+| `FEISHU_ALLOW_BOTS` | — | `none` | Accept messages from other bots: `none`, `mentions`, or `all` |\n+| `FEISHU_REQUIRE_MENTION` | — | `true` | Whether group messages must @mention the bot |\n | `FEISHU_HOME_CHANNEL` | — | — | Chat ID for cron/notification output |\n | `FEISHU_ENCRYPT_KEY` | — | _(empty)_ | Encrypt key for webhook signature verification |\n | `FEISHU_VERIFICATION_TOKEN` | — | _(empty)_ | Verification token for webhook payload auth |","@@ -487,7 +520,9 @@ WebSocket and per-group ACL settings are configured via `config.yaml` under `pla\n | `Webhook rejected: invalid signature` | Ensure `FEISHU_ENCRYPT_KEY` matches the encrypt key in your Feishu app config |\n | Post messages show as plain text | The Feishu API rejected the post payload; this is normal fallback behavior. Check logs for details. |\n | Images/files not received by bot | Grant `im:message` and `im:resource` permission scopes to your Feishu app |\n-| Bot identity not auto-detected | Grant `admin:app.info:readonly` scope, or set `FEISHU_BOT_OPEN_ID` / `FEISHU_BOT_NAME` manually |\n+| Bot identity not auto-detected | Usually a transient network issue reaching Feishu's bot info endpoint. Set `FEISHU_BOT_OPEN_ID` and `FEISHU_BOT_NAME` manually as a workaround. |\n+| Peer bot messages still ignored after enabling `FEISHU_ALLOW_BOTS` | Hermes can't identify itself yet — set `FEISHU_BOT_OPEN_ID` (and `FEISHU_BOT_USER_ID` if your app uses `sender_id_type=user_id`). |\n+| Peer bots show as `ou_xxxxxx` instead of by name | Grant the `application:bot.basic_info:read` scope. |\n | Error 200340 when clicking approval buttons | Enable **Interactive Card** capability and configure **Card Request URL** in the Feishu Developer Console. See [Required Feishu App Configuration](#required-feishu-app-configuration) above. |\n | `Webhook rate limit exceeded` | More than 120 requests/minute from the same IP. This is usually a misconfiguration or loop. |\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}