{"repo":"NousResearch/hermes-agent","public_id":"1788","display_label":"Feishu/Lark messaging platform gateway support","summary":"Multiple requests and proposed implementations for adding Feishu (also known as Lark) as a supported messaging platform or gateway adapter. The cluster includes both feature requests from users and code contributions toward this integration.","subsystem":"gateway","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3817},"resolution":{"state":"partially_resolved","by":"pr-3817"},"freshness":{"state":"stale","last_verified_at":"2026-03-30T01:17:45+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33866666666666667},"fix_edges":[{"issue_number":1788,"pr_number":3817,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":2737,"pr_number":3817,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":1788,"title":"[Feature]: Add Feishu as a supported gateway / messaging platform","url":null,"state":"closed"},{"kind":"issue","number":2737,"title":"[Feature]: Add support for Feishu (Lark) as a messaging channel","url":null,"state":"closed"},{"kind":"issue","number":3663,"title":"Feature Request: Lark/Feishu (飞书) Platform Support","url":null,"state":"closed"},{"kind":"pr","number":1793,"title":"feat(gateway): add Feishu platform support","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3817","number":3817}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2738,"title":"feat(gateway): add Feishu (Lark) platform adapter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ca4907dfb","number":0},{"kind":"pr","value":"#3817","number":3817}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":3817,"title":"feat(gateway): add Feishu/Lark platform support","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","gateway/config.py","gateway/platforms/feishu.py","gateway/run.py","hermes_cli/config.py","hermes_cli/gateway.py","hermes_cli/skills_config.py","hermes_cli/tools_config.py","pyproject.toml","tests/gateway/test_allowlist_startup_check.py","tests/gateway/test_feishu.py","tests/gateway/test_unauthorized_dm_behavior.py","tools/cronjob_tools.py","tools/send_message_tool.py","toolsets.py","website/docs/reference/toolsets-reference.md","website/docs/user-guide/messaging/feishu.md","website/docs/user-guide/messaging/index.md","website/sidebars.ts"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -145,6 +145,7 @@ def _deliver_result(job: dict, content: str) -> None:\n         \"mattermost\": Platform.MATTERMOST,\n         \"homeassistant\": Platform.HOMEASSISTANT,\n         \"dingtalk\": Platform.DINGTALK,\n+        \"feishu\": Platform.FEISHU,\n         \"email\": Platform.EMAIL,\n         \"sms\": Platform.SMS,\n     }"]},{"file":"gateway/config.py","hunks":["@@ -57,6 +57,7 @@ class Platform(Enum):\n     DINGTALK = \"dingtalk\"\n     API_SERVER = \"api_server\"\n     WEBHOOK = \"webhook\"\n+    FEISHU = \"feishu\"\n \n \n @dataclass","@@ -274,6 +275,9 @@ def get_connected_platforms(self) -> List[Platform]:\n             # Webhook uses enabled flag only (secrets are per-route)\n             elif platform == Platform.WEBHOOK:\n                 connected.append(platform)\n+            # Feishu uses extra dict for app credentials\n+            elif platform == Platform.FEISHU and config.extra.get(\"app_id\"):\n+                connected.append(platform)\n         return connected\n     \n     def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:","@@ -811,6 +815,33 @@ def _apply_env_overrides(config: GatewayConfig) -> None:\n         if webhook_secret:\n             [redacted:secret-assignment]\"secret\"] = webhook_secret\n \n+    # Feishu / Lark\n+    feishu_app_id = os.getenv(\"FEISHU_APP_ID\")\n+    feishu_app_secret = [redacted:secret-assignment]\"FEISHU_APP_SECRET\")\n+    if feishu_app_id and feishu_app_secret:\n+        if Platform.FEISHU not in config.platforms:\n+            config.platforms[Platform.FEISHU] = PlatformConfig()\n+        config.platforms[Platform.FEISHU].enabled = True\n+        config.platforms[Platform.FEISHU].extra.update({\n+            \"app_id\": feishu_app_id,\n+            \"app_secret\": feishu_app_secret,\n+            \"domain\": os.getenv(\"FEISHU_DOMAIN\", \"feishu\"),\n+            \"connection_mode\": os.getenv(\"FEISHU_CONNECTION_MODE\", \"websocket\"),\n+        })\n+        feishu_encrypt_key = os.getenv(\"FEISHU_ENCRYPT_KEY\", \"\")\n+        if feishu_encrypt_key:\n+            config.platforms[Platform.FEISHU].extra[\"encrypt_key\"] = feishu_encrypt_key\n+        feishu_verification_token = [redacted:secret-assignment]\"FEISHU_VERIFICATION_TOKEN\", \"\")\n+        if feishu_verification_token:\n+            config.platforms[Platform.FEISHU].extra[\"verification_token\"] = feishu_verification_token\n+        feishu_home = os.getenv(\"FEISHU_HOME_CHANNEL\")\n+        if feishu_home:\n+            config.platforms[Platform.FEISHU].home_channel = HomeChannel(\n+                platform=Platform.FEISHU,\n+                chat_id=feishu_home,\n+                name=os.getenv(\"FEISHU_HOME_CHANNEL_NAME\", \"Home\"),\n+            )\n+\n     # Session settings\n     idle_minutes = os.getenv(\"SESSION_IDLE_MINUTES\")\n     if idle_minutes:"]},{"file":"gateway/platforms/feishu.py","hunks":["@@ -0,0 +1,3255 @@\n+\"\"\"\n+Feishu/Lark platform adapter.\n+\n+Supports:\n+- WebSocket long connection and Webhook transport\n+- Direct-message and group @mention-gated text receive/send\n+- Inbound image/file/audio/media caching\n+- Gateway allowlist integration via FEISHU_ALLOWED_USERS\n+- Persistent dedup state across restarts\n+- Per-chat serial message processing (matches openclaw createChatQueue)\n+- Persistent ACK emoji reaction on inbound messages\n+- Reaction events routed as synthetic text events (matches openclaw)\n+- Interactive card button-click events routed as synthetic COMMAND events\n+- Webhook anomaly tracking (matches openclaw createWebhookAnomalyTracker)\n+- Verification token validation as second auth layer (matches openclaw)\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import asyncio\n+import hashlib\n+import hmac\n+import json\n+import logging\n+import mimetypes\n+import os\n+import re\n+import threading\n+import time\n+import uuid\n+from dataclasses import dataclass, field\n+from datetime import datetime\n+from pathlib import Path\n+from types import SimpleNamespace\n+from typing import Any, Dict, List, Optional\n+\n+# aiohttp/websockets are independent optional deps — import outside lark_oapi\n+# so they remain available for tests and webhook mode even if lark_oapi is missing.\n+try:\n+    import aiohttp\n+    from aiohttp import web\n+except ImportError:\n+    aiohttp = None  # type: ignore[assignment]\n+    web = None  # type: ignore[assignment]\n+\n+try:\n+    import websockets\n+except ImportError:\n+    websockets = None  # type: ignore[assignment]\n+\n+try:\n+    import lark_oapi as lark\n+    from lark_oapi.api.application.v6 import GetApplicationRequest\n+    from lark_oapi.api.im.v1 import (\n+        CreateFileRequest,\n+        CreateFileRequestBody,\n+        CreateImageRequest,\n+        CreateImageRequestBody,\n+        CreateMessageRequest,\n+        CreateMessageRequestBody,\n+        GetChatRequest,\n+        GetMessageRequest,\n+        GetImageRequest,\n+        GetMessageResourceRequest,\n+        P2ImMessageMessageReadV1,\n+        ReplyMessageRequest,\n+        ReplyMessageRequestBody,\n+        UpdateMessageRequest,\n+        UpdateMessageRequestBody,\n+    )\n+    from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN\n+    from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse\n+    from lark_oapi.event.dispatcher_handler import EventDispatcherHandler\n+    from lark_oapi.ws import Client as FeishuWSClient\n+\n+    FEISHU_AVAILABLE = True\n+except ImportError:\n+    FEISHU_AVAILABLE = False\n+    lark = None  # type: ignore[assignment]\n+    P2CardActionTriggerResponse = None  # type: ignore[assignment]\n+    EventDispatcherHandler = None  # type: ignore[assignment]\n+    FeishuWSClient = None  # type: ignore[assignment]\n+    FEISHU_DOMAIN = None  # type: ignore[assignment]\n+    LARK_DOMAIN = None  # type: ignore[assignment]\n+\n+FEISHU_WEBSOCKET_AVAILABLE = websockets is not None\n+FEISHU_WEBHOOK_AVAILABLE = aiohttp is not None\n+\n+from gateway.config import Platform, PlatformConfig\n+from gateway.platforms.base import (\n+    BasePlatformAdapter,\n+    MessageEvent,\n+    MessageType,\n+    SendResult,\n+    SUPPORTED_DOCUMENT_TYPES,\n+    cache_document_from_bytes,\n+    cache_image_from_url,\n+    cache_audio_from_bytes,\n+    cache_image_from_bytes,\n+)\n+from gateway.status import acquire_scoped_lock, release_scoped_lock\n+from hermes_constants import get_hermes_home\n+\n+logger = logging.getLogger(__name__)\n+\n+# ---------------------------------------------------------------------------\n+# Regex patterns\n+# ---------------------------------------------------------------------------\n+\n+_MARKDOWN_HINT_RE = re.compile(\n+    r\"(^#{1,6}\\s)|(^\\s*[-*]\\s)|(^\\s*\\d+\\.\\s)|(^\\s*---+\\s*$)|(```)|(`[^`\\n]+`)|(\\*\\*[^*\\n].+?\\*\\*)|(~~[^~\\n].+?~~)|(<u>.+?</u>)|(\\*[^*\\n]+\\*)|(\\[[^\\]]+\\]\\([^)]+\\))|(^>\\s)\",\n+    re.MULTILINE,\n+)\n+_MARKDOWN_LINK_RE = re.compile(r\"\\[([^\\]]+)\\]\\(([^)]+)\\)\")\n+_MENTION_RE = re.compile(r\"@_user_\\d+\")\n+_MULTISPACE_RE = re.compile(r\"[ \\t]{2,}\")\n+_POST_CONTENT_INVALID_RE = re.compile(r\"content format of the post type is incorrect\", re.IGNORECASE)\n+# ---------------------------------------------------------------------------\n+# Media type sets and upload constants\n+# ---------------------------------------------------------------------------\n+\n+_IMAGE_EXTENSIONS = {\".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\", \".bmp\"}\n+_AUDIO_EXTENSIONS = {\".ogg\", \".mp3\", \".wav\", \".m4a\", \".aac\", \".flac\", \".opus\", \".webm\"}\n+_VIDEO_EXTENSIONS = {\".mp4\", \".mov\", \".avi\", \".mkv\", \".webm\", \".m4v\", \".3gp\"}\n+_DOCUMENT_MIME_TO_EXT = {mime: ext for ext, mime in SUPPORTED_DOCUMENT_TYPES.items()}\n+_FEISHU_IMAGE_UPLOAD_TYPE = \"message\"\n+_FEISHU_FILE_UPLOAD_TYPE = \"stream\"\n+_FEISHU_OPUS_UPLOAD_EXTENSIONS = {\".ogg\", \".opus\"}\n+_FEISHU_MEDIA_UPLOAD_EXTENSIONS = {\".mp4\", \".mov\", \".avi\", \".m4v\"}\n+_FEISHU_DOC_UPLOAD_TYPES = {\n+    \".pdf\": \"pdf\",\n+    \".doc\": \"doc\",\n+    \".docx\": \"doc\",\n+    \".xls\": \"xls\",\n+    \".xlsx\": \"xls\",\n+    \".ppt\": \"ppt\",\n+    \".pptx\": \"ppt\",\n+}\n+# ---------------------------------------------------------------------------\n+# Connection, retry and batching tuning\n+# ---------------------------------------------------------------------------\n+\n+_MAX_TEXT_INJECT_BYTES = 100 * 1024\n+_FEISHU_CONNECT_ATTEMPTS = 3\n+_FEISHU_SEND_ATTEMPTS = 3\n+_FEISHU_APP_LOCK_SCOPE = \"feishu-app-id\"\n+_DEFAULT_TEXT_BATCH_DELAY_SECONDS = 0.6\n+_DEFAULT_TEXT_BATCH_MAX_MESSAGES = 8\n+_DEFAULT_TEXT_BATCH_MAX_CHARS = 4000\n+_DEFAULT_MEDIA_BATCH_DELAY_SECONDS = 0.8\n+_DEFAULT_DEDUP_CACHE_SIZE = 2048\n+_DEFAULT_WEBHOOK_HOST = \"127.0.0.1\"\n+_DEFAULT_WEBHOOK_PORT = 8765\n+_DEFAULT_WEBHOOK_PATH = \"/feishu/webhook\"\n+# ---------------------------------------------------------------------------\n+# TTL, rate-limit and webhook security constants\n+# ---------------------------------------------------------------------------\n+\n+_FEISHU_DEDUP_TTL_SECONDS = 24 * 60 * 60          # 24 hours — matches openclaw\n+_FEISHU_SENDER_NAME_TTL_SECONDS = 10 * 60          # 10 minutes sender-name cache\n+_FEISHU_WEBHOOK_MAX_BODY_BYTES = 1 * 1024 * 1024   # 1 MB body limit\n+_FEISHU_WEBHOOK_RATE_WINDOW_SECONDS = 60            # sliding window for rate limiter\n+_FEISHU_WEBHOOK_RATE_LIMIT_MAX = 120               # max requests per window per IP — matches openclaw\n+_FEISHU_WEBHOOK_RATE_MAX_KEYS = 4096               # max tracked keys (prevents unbounded growth)\n+_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS = 30          # max seconds to read request body\n+_FEISHU_WEBHOOK_ANOMALY_THRESHOLD = 25             # consecutive error responses before WARNING log\n+_FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60  # anomaly tracker TTL (6 hours) — matches openclaw\n+_FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS = 15 * 60    # card action token dedup window (15 min)\n+_FEISHU_BOT_MSG_TRACK_SIZE = 512                   # LRU size for tracking sent message IDs\n+_FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003})  # reply target withdrawn/missing → create fallback\n+_FEISHU_ACK_EMOJI = \"OK\"\n+# ---------------------------------------------------------------------------\n+# Fallback display strings\n+# ---------------------------------------------------------------------------\n+\n+FALLBACK_POST_TEXT = \"[Rich text message]\"\n+FALLBACK_FORWARD_TEXT = \"[Merged forward message]\"\n+FALLBACK_SHARE_CHAT_TEXT = \"[Shared chat]\"\n+FALLBACK_INTERACTIVE_TEXT = \"[Interactive message]\"\n+FALLBACK_IMAGE_TEXT = \"[Image]\"\n+FALLBACK_ATTACHMENT_TEXT = \"[Attachment]\"\n+# ---------------------------------------------------------------------------\n+# Post/card parsing helpers\n+# ---------------------------------------------------------------------------\n+\n+_PREFERRED_LOCALES = (\"zh_cn\", \"en_us\")\n+_MARKDOWN_SPECIAL_CHARS_RE = re.compile(r\"([\\\\`*_{}\\[\\]()#+\\-!|>~])\")\n+_MENTION_PLACEHOLDER_RE = re.compile(r\"@_user_\\d+\")\n+_WHITESPACE_RE = re.compile(r\"\\s+\")\n+_SUPPORTED_CARD_TEXT_KEYS = (\n+    \"title\",\n+    \"text\",\n+    \"content\",\n+    \"label\",\n+    \"value\",\n+    \"name\",\n+    \"summary\",\n+    \"subtitle\",\n+    \"description\",\n+    \"placeholder\",\n+    \"hint\",\n+)\n+_SKIP_TEXT_KEYS = {\n+    \"tag\",\n+    \"type\",\n+    \"msg_type\",\n+    \"message_type\",\n+    \"chat_id\",\n+    \"open_chat_id\",\n+    \"share_chat_id\",\n+    \"file_key\",\n+    \"image_key\",\n+    \"user_id\",\n+    \"open_id\",\n+    \"union_id\",\n+    \"url\",\n+    \"href\",\n+    \"link\",\n+    \"token\",\n+    \"template\",\n+    \"locale\",\n+}\n+\n+\n+@dataclass(frozen=True)\n+class FeishuPostMediaRef:\n+    file_key: str\n+    file_name: str = \"\"\n+    resource_type: str = \"file\"\n+\n+\n+@dataclass(frozen=True)\n+class FeishuPostParseResult:\n+    text_content: str\n+    image_keys: List[str] = field(default_factory=list)\n+    media_refs: List[FeishuPostMediaRef] = field(default_factory=list)\n+    mentioned_ids: List[str] = field(default_factory=list)\n+\n+\n+@dataclass(frozen=True)\n+class FeishuNormalizedMessage:\n+    raw_type: str\n+    text_content: str\n+    preferred_message_type: str = \"text\"\n+    image_keys: List[str] = field(default_factory=list)\n+    media_refs: List[FeishuPostMediaRef] = field(default_factory=list)\n+    mentioned_ids: List[str] = field(default_factory=list)\n+    relation_kind: str = \"plain\"\n+    metadata: Dict[str, Any] = field(default_factory=dict)\n+\n+\n+@dataclass(frozen=True)\n+class FeishuAdapterSettings:\n+    app_id: str\n+    app_secret: str\n+    domain_name: str\n+    connection_mode: str\n+    encrypt_key: str\n+    verification_token: str\n+    group_policy: str\n+    allowed_group_users: frozenset[str]\n+    bot_open_id: str\n+    bot_user_id: str\n+    bot_name: str\n+    dedup_cache_size: int\n+    text_batch_delay_seconds: float\n+    text_batch_max_messages: int\n+    text_batch_max_chars: int\n+    media_batch_delay_seconds: float\n+    webhook_host: str\n+    webhook_port: int\n+    webhook_path: str\n+\n+\n+@dataclass\n+class FeishuBatchState:\n+    events: Dict[str, MessageEvent] = field(default_factory=dict)\n+    tasks: Dict[str, asyncio.Task] = field(default_factory=dict)\n+    counts: Dict[str, int] = field(default_factory=dict)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Markdown rendering helpers\n+# ---------------------------------------------------------------------------\n+\n+\n+def _escape_markdown_text(text: str) -> str:\n+    return _MARKDOWN_SPECIAL_CHARS_RE.sub(r\"\\\\\\1\", text)\n+\n+\n+def _to_boolean(value: Any) -> bool:\n+    return value is True or value == 1 or value == \"true\"\n+\n+\n+def _is_style_enabled(style: Dict[str, Any] | None, key: str) -> bool:\n+    if not style:\n+        return False\n+    return _to_boolean(style.get(key))\n+\n+\n+def _wrap_inline_code(text: str) -> str:\n+    max_run = max([0, *[len(run) for run in re.findall(r\"`+\", text)]])\n+    fence = \"`\" * (max_run + 1)\n+    body = f\" {text} \" if text.startswith(\"`\") or text.endswith(\"`\") else text\n+    return f\"{fence}{body}{fence}\"\n+\n+\n+def _sanitize_fence_language(language: str) -> str:\n+    return language.strip().replace(\"\\n\", \" \").replace(\"\\r\", \" \")\n+\n+\n+def _render_text_element(element: Dict[str, Any]) -> str:\n+    text = str(element.get(\"text\", \"\") or \"\")\n+    style = element.get(\"style\")\n+    style_dict = style if isinstance(style, dict) else None\n+\n+    if _is_style_enabled(style_dict, \"code\"):\n+        return _wrap_inline_code(text)\n+\n+    rendered = _escape_markdown_text(text)\n+    if not rendered:\n+        return \"\"\n+    if _is_style_enabled(style_dict, \"bold\"):\n+        rendered = f\"**{rendered}**\"\n+    if _is_style_enabled(style_dict, \"italic\"):\n+        rendered = f\"*{rendered}*\"\n+    if _is_style_enabled(style_dict, \"underline\"):\n+        rendered = f\"<u>{rendered}</u>\"\n+    if _is_style_enabled(style_dict, \"strikethrough\"):\n+        rendered = f\"~~{rendered}~~\"\n+    return rendered\n+\n+\n+def _render_code_block_element(element: Dict[str, Any]) -> str:\n+    language = _sanitize_fence_language(\n+        str(element.get(\"language\", \"\") or \"\") or str(element.get(\"lang\", \"\") or \"\")\n+    )\n+    code = (\n+        str(element.get(\"text\", \"\") or \"\") or str(element.get(\"content\", \"\") or \"\")\n+    ).replace(\"\\r\\n\", \"\\n\")\n+    trailing_newline = \"\" if code.endswith(\"\\n\") else \"\\n\"\n+    return f\"```{language}\\n{code}{trailing_newline}```\"\n+\n+\n+def _strip_markdown_to_plain_text(text: str) -> str:\n+    plain = text.replace(\"\\r\\n\", \"\\n\")\n+    plain = _MARKDOWN_LINK_RE.sub(lambda m: f\"{m.group(1)} ({m.group(2).strip()})\", plain)\n+    plain = re.sub(r\"^#{1,6}\\s+\", \"\", plain, flags=re.MULTILINE)\n+    plain = re.sub(r\"^>\\s?\", \"\", plain, flags=re.MULTILINE)\n+    plain = re.sub(r\"^\\s*---+\\s*$\", \"---\", plain, flags=re.MULTILINE)\n+    plain = re.sub(r\"```(?:[^\\n]*\\n)?([\\s\\S]*?)```\", lambda m: m.group(1).strip(\"\\n\"), plain)\n+    plain = re.sub(r\"`([^`\\n]+)`\", r\"\\1\", plain)\n+    plain = re.sub(r\"\\*\\*([^*\\n]+)\\*\\*\", r\"\\1\", plain)\n+    plain = re.sub(r\"\\*([^*\\n]+)\\*\", r\"\\1\", plain)\n+    plain = re.sub(r\"~~([^~\\n]+)~~\", r\"\\1\", plain)\n+    plain = re.sub(r\"<u>([\\s\\S]*?)</u>\", r\"\\1\", plain)\n+    plain = re.sub(r\"\\n{3,}\", \"\\n\\n\", plain)\n+    return plain.strip()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Post payload builders and parsers\n+# ---------------------------------------------------------------------------\n+\n+\n+def _build_markdown_post_payload(content: str) -> str:\n+    return json.dumps(\n+        {\n+            \"zh_cn\": {\n+                \"content\": [\n+                    [\n+                        {\n+                            \"tag\": \"md\",\n+                            \"text\": content,\n+                        }\n+                    ]\n+                ],\n+            }\n+        },\n+        ensure_ascii=False,\n+    )\n+\n+\n+def parse_feishu_post_content(raw_content: str) -> FeishuPostParseResult:\n+    try:\n+        parsed = json.loads(raw_content) if raw_content else {}\n+    except json.JSONDecodeError:\n+        return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT)\n+    return parse_feishu_post_payload(parsed)\n+\n+\n+def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult:\n+    resolved = _resolve_post_payload(payload)\n+    if not resolved:\n+        return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT)\n+\n+    image_keys: List[str] = []\n+    media_refs: List[FeishuPostMediaRef] = []\n+    mentioned_ids: List[str] = []\n+    parts: List[str] = []\n+\n+    title = _normalize_feishu_text(str(resolved.get(\"title\", \"\")).strip())\n+    if title:\n+        parts.append(title)\n+\n+    for row in resolved.get(\"content\", []) or []:\n+        if not isinstance(row, list):\n+            continue\n+        row_text = _normalize_feishu_text(\n+            \"\".join(_render_post_element(item, image_keys, media_refs, mentioned_ids) for item in row)\n+        )\n+        if row_text:\n+            parts.append(row_text)\n+\n+    return FeishuPostParseResult(\n+        text_content=\"\\n\".join(parts).strip() or FALLBACK_POST_TEXT,\n+        image_keys=image_keys,\n+        media_refs=media_refs,\n+        mentioned_ids=mentioned_ids,\n+    )\n+\n+\n+def _resolve_post_payload(payload: Any) -> Dict[str, Any]:\n+    direct = _to_post_payload(payload)\n+    if direct:\n+        return direct\n+    if not isinstance(payload, dict):\n+        return {}\n+\n+    wrapped = payload.get(\"post\")\n+    wrapped_direct = _resolve_locale_payload(wrapped)\n+    if wrapped_direct:\n+        return wrapped_direct\n+    return _resolve_locale_payload(payload)\n+\n+\n+def _resolve_locale_payload(payload: Any) -> Dict[str, Any]:\n+    direct = _to_post_payload(payload)\n+    if direct:\n+        return direct\n+    if not isinstance(payload, dict):\n+        return {}\n+\n+    for key in _PREFERRED_LOCALES:\n+        candidate = _to_post_payload(payload.get(key))\n+        if candidate:\n+            return candidate\n+    for value in payload.values():\n+        candidate = _to_post_payload(value)\n+        if candidate:\n+            return candidate\n+    return {}\n+\n+\n+def _to_post_payload(candidate: Any) -> Dict[str, Any]:\n+    if not isinstance(candidate, dict):\n+        return {}\n+    content = candidate.get(\"content\")\n+    if not isinstance(content, list):\n+        return {}\n+    return {\n+        \"title\": str(candidate.get(\"title\", \"\") or \"\"),\n+        \"content\": content,\n+    }\n+\n+\n+def _render_post_element(\n+    element: Any,\n+    image_keys: List[str],\n+    media_refs: List[FeishuPostMediaRef],\n+    mentioned_ids: List[str],\n+) -> str:\n+    if isinstance(element, str):\n+        return element\n+    if not isinstance(element, dict):\n+        return \"\"\n+\n+    tag = str(element.get(\"tag\", \"\")).strip().lower()\n+    if tag == \"text\":\n+        return _render_text_element(element)\n+    if tag == \"a\":\n+        href = str(element.get(\"href\", \"\")).strip()\n+        label = str(element.get(\"text\", href) or \"\").strip()\n+        if not label:\n+            return \"\"\n+        escaped_label = _escape_markdown_text(label)\n+        return f\"[{escaped_label}]({href})\" if href else escaped_label\n+    if tag == \"at\":\n+        mentioned_id = (\n+            str(element.get(\"open_id\", \"\")).strip()\n+            or str(element.get(\"user_id\", \"\")).strip()\n+        )\n+        if mentioned_id and mentioned_id not in mentioned_ids:\n+            mentioned_ids.append(mentioned_id)\n+        display_name = (\n+            str(element.get(\"user_name\", \"\")).strip()\n+            or str(element.get(\"name\", \"\")).strip()\n+            or str(element.get(\"text\", \"\")).strip()\n+            or mentioned_id\n+        )\n+        return f\"@{_escape_markdown_text(display_name)}\" if display_name else \"@\"\n+    if tag in {\"img\", \"image\"}:\n+        image_key = str(element.get(\"image_key\", \"\")).strip()\n+        if image_key and image_key not in image_keys:\n+            image_keys.append(image_key)\n+        alt = str(element.get(\"text\", \"\")).strip() or str(element.get(\"alt\", \"\")).strip()\n+        return f\"[Image: {alt}]\" if alt else \"[Image]\"\n+    if tag in {\"media\", \"file\", \"audio\", \"video\"}:\n+        file_key = str(element.get(\"file_key\", \"\")).strip()\n+        file_name = (\n+            str(element.get(\"file_name\", \"\")).strip()\n+            or str(element.get(\"title\", \"\")).strip()\n+            or str(element.get(\"text\", \"\")).strip()\n+        )\n+        if file_key:\n+            media_refs.append(\n+                FeishuPostMediaRef(\n+                    file_key=file_key,\n+                    file_name=file_name,\n+                    resource_type=tag if tag in {\"audio\", \"video\"} else \"file\",\n+                )\n+            )\n+        return f\"[Attachment: {file_name}]\" if file_name else \"[Attachment]\"\n+    if tag in {\"emotion\", \"emoji\"}:\n+        label = str(element.get(\"text\", \"\")).strip() or str(element.get(\"emoji_type\", \"\")).strip()\n+        return f\":{_escape_markdown_text(label)}:\" if label else \"[Emoji]\"\n+    if tag == \"br\":\n+        return \"\\n\"\n+    if tag in {\"hr\", \"divider\"}:\n+        return \"\\n\\n---\\n\\n\"\n+    if tag == \"code\":\n+        code = str(element.get(\"text\", \"\") or \"\") or str(element.get(\"content\", \"\") or \"\")\n+        return _wrap_inline_code(code) if code else \"\"\n+    if tag in {\"code_block\", \"pre\"}:\n+        return _render_code_block_element(element)\n+\n+    nested_parts: List[str] = []\n+    for key in (\"text\", \"title\", \"content\", \"children\", \"elements\"):\n+        value = element.get(key)\n+        extracted = _render_nested_post(value, image_keys, media_refs, mentioned_ids)\n+        if extracted:\n+            nested_parts.append(extracted)\n+    return \" \".join(part for part in nested_parts if part)\n+\n+\n+def _render_nested_post(\n+    value: Any,\n+    image_keys: List[str],\n+    media_refs: List[FeishuPostMediaRef],\n+    mentioned_ids: List[str],\n+) -> str:\n+    if isinstance(value, str):\n+        return _escape_markdown_text(value)\n+    if isinstance(value, list):\n+        return \" \".join(\n+            part\n+            for item in value\n+            for part in [_render_nested_post(item, image_keys, media_refs, mentioned_ids)]\n+            if part\n+        )\n+    if isinstance(value, dict):\n+        direct = _render_post_element(value, image_keys, media_refs, mentioned_ids)\n+        if direct:\n+            return direct\n+        return \" \".join(\n+            part\n+            for item in value.values()\n+            for part in [_render_nested_post(item, image_keys, media_refs, mentioned_ids)]\n+            if part\n+        )\n+    return \"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Message normalization\n+# ---------------------------------------------------------------------------\n+\n+\n+def normalize_feishu_message(*, message_type: str, raw_content: str) -> FeishuNormalizedMessage:\n+    normalized_type = str(message_type or \"\").strip().lower()\n+    payload = _load_feishu_payload(raw_content)\n+\n+    if normalized_type == \"text\":\n+        return FeishuNormalizedMessage(\n+            raw_type=normalized_type,\n+            text_content=_normalize_feishu_text(str(payload.get(\"text\", \"\") or \"\")),\n+        )\n+    if normalized_type == \"post\":\n+        parsed_post = parse_feishu_post_payload(payload)\n+        return FeishuNormalizedMessage(\n+            raw_type=normalized_type,\n+            text_content=parsed_post.text_content,\n+            image_keys=list(parsed_post.image_keys),\n+            media_refs=list(parsed_post.media_refs),\n+            mentioned_ids=list(parsed_post.mentioned_ids),\n+            relation_kind=\"post\",\n+        )\n+    if normalized_type == \"image\":\n+        image_key = str(payload.get(\"image_key\", \"\") or \"\").strip()\n+        alt_text = _normalize_feishu_text(\n+            str(payload.get(\"text\", \"\") or \"\")\n+            or str(payload.get(\"alt\", \"\") or \"\")\n+            or FALLBACK_IMAGE_TEXT\n+        )\n+        return FeishuNormalizedMessage(\n+            raw_type=normalized_type,\n+            text_content=alt_text if alt_text != FALLBACK_IMAGE_TEXT else \"\",\n+            preferred_message_type=\"photo\",\n+            image_keys=[image_key] if image_key else [],\n+            relation_kind=\"image\",\n+        )\n+    if normalized_type in {\"file\", \"audio\", \"media\"}:\n+        media_ref = _build_media_ref_from_payload(payload, resource_type=normalized_type)\n+        placeholder = _attachment_placeholder(media_ref.file_name)\n+        return FeishuNormalizedMessage(\n+            raw_type=normalized_type,\n+            text_content=\"\",\n+            preferred_message_type=\"audio\" if normalized_type == \"audio\" else \"document\",\n+            media_refs=[media_ref] if media_ref.file_key else [],\n+            relation_kind=normalized_type,\n+            metadata={\"placeholder_text\": placeholder},\n+        )\n+    if normalized_type == \"merge_forward\":\n+        return _normalize_merge_forward_message(payload)\n+    if normalized_type == \"share_chat\":\n+        return _normalize_share_chat_message(payload)\n+    if normalized_type in {\"interactive\", \"card\"}:\n+        return _normalize_interactive_message(normalized_type, payload)\n+\n+    return FeishuNormalizedMessage(raw_type=normalized_type, text_content=\"\")\n+\n+\n+def _load_feishu_payload(raw_content: str) -> Dict[str, Any]:\n+    try:\n+        parsed = json.loads(raw_content) if raw_content else {}\n+    except json.JSONDecodeError:\n+        return {\"text\": raw_content}\n+    return parsed if isinstance(parsed, dict) else {\"content\": parsed}\n+\n+\n+def _normalize_merge_forward_message(payload: Dict[str, Any]) -> FeishuNormalizedMessage:\n+    title = _first_non_empty_text(\n+        payload.get(\"title\"),\n+        payload.get(\"summary\"),\n+        payload.get(\"preview\"),\n+        _find_first_text(payload, keys=(\"title\", \"summary\", \"preview\", \"description\")),\n+    )\n+    entries = _collect_forward_entries(payload)\n+    lines: List[str] = []\n+    if title:\n+        lines.append(title)\n+    lines.extend(entries[:8])\n+    text_content = \"\\n\".join(lines).strip() or FALLBACK_FORWARD_TEXT\n+    return FeishuNormalizedMessage(\n+        raw_type=\"merge_forward\",\n+        text_content=text_content,\n+        relation_kind=\"merge_forward\",\n+        metadata={\"entry_count\": len(entries), \"title\": title},\n+    )\n+\n+\n+def _normalize_share_chat_message(payload: Dict[str, Any]) -> FeishuNormalizedMessage:\n+    chat_name = _first_non_empty_text(\n+        payload.get(\"chat_name\"),\n+        payload.get(\"name\"),\n+        payload.get(\"title\"),\n+        _find_first_text(payload, keys=(\"chat_name\", \"name\", \"title\")),\n+    )\n+    share_id = _first_non_empty_text(\n+        payload.get(\"chat_id\"),\n+        payload.get(\"open_chat_id\"),\n+        payload.get(\"share_chat_id\"),\n+    )\n+    lines = []\n+    if chat_name:\n+        lines.append(f\"Shared chat: {chat_name}\")\n+    else:\n+        lines.append(FALLBACK_SHARE_CHAT_TEXT)\n+    if share_id:\n+        lines.append(f\"Chat ID: {share_id}\")\n+    text_content = \"\\n\".join(lines)\n+    return FeishuNormalizedMessage(\n+        raw_type=\"share_chat\",\n+        text_content=text_content,\n+        relation_kind=\"share_chat\",\n+        metadata={\"chat_id\": share_id, \"chat_name\": chat_name},\n+    )\n+\n+\n+def _normalize_interactive_message(message_type: str, payload: Dict[str, Any]) -> FeishuNormalizedMessage:\n+    card_payload = payload.get(\"card\") if isinstance(payload.get(\"card\"), dict) else payload\n+    title = _first_non_empty_text(\n+        _find_header_title(card_payload),\n+        payload.get(\"title\"),\n+        _find_first_text(card_payload, keys=(\"title\", \"summary\", \"subtitle\")),\n+    )\n+    body_lines = _collect_card_lines(card_payload)\n+    actions = _collect_action_labels(card_payload)\n+\n+    lines: List[str] = []\n+    if title:\n+        lines.append(title)\n+    for line in body_lines:\n+        if line != title:\n+            lines.append(line)\n+    if actions:\n+        lines.append(f\"Actions: {', '.join(actions)}\")\n+\n+    text_content = \"\\n\".join(lines[:12]).strip() or FALLBACK_INTERACTIVE_TEXT\n+    return FeishuNormalizedMessage(\n+        raw_type=message_type,\n+        text_content=text_content,\n+        relation_kind=\"interactive\",\n+        metadata={\"title\": title, \"actions\": actions},\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Content extraction utilities (card / forward / text walking)\n+# ---------------------------------------------------------------------------\n+\n+\n+def _collect_forward_entries(payload: Dict[str, Any]) -> List[str]:\n+    candidates: List[Any] = []\n+    for key in (\"messages\", \"items\", \"message_list\", \"records\", \"content\"):\n+        value = payload.get(key)\n+        if isinstance(value, list):\n+            candidates.extend(value)\n+    entries: List[str] = []\n+    for item in candidates:\n+        if not isinstance(item, dict):\n+            text = _normalize_feishu_text(str(item or \"\"))\n+            if text:\n+                entries.append(f\"- {text}\")\n+            continue\n+        sender = _first_non_empty_text(\n+            item.get(\"sender_name\"),\n+            item.get(\"user_name\"),\n+            item.get(\"sender\"),\n+            item.get(\"name\"),\n+        )\n+        nested_type = str(item.get(\"message_type\", \"\") or item.get(\"msg_type\", \"\")).strip().lower()\n+        if nested_type == \"post\":\n+            body = parse_feishu_post_payload(item.get(\"content\") or item).text_content\n+        else:\n+            body = _first_non_empty_text(\n+                item.get(\"text\"),\n+                item.get(\"summary\"),\n+                item.get(\"preview\"),\n+                item.get(\"content\"),\n+                _find_first_text(item, keys=(\"text\", \"content\", \"summary\", \"preview\", \"title\")),\n+            )\n+        body = _normalize_feishu_text(body)\n+        if sender and body:\n+            entries.append(f\"- {sender}: {body}\")\n+        elif body:\n+            entries.append(f\"- {body}\")\n+    return _unique_lines(entries)\n+\n+\n+def _collect_card_lines(payload: Any) -> List[str]:\n+    lines = _collect_text_segments(payload, in_rich_block=False)\n+    normalized = [_normalize_feishu_text(line) for line in lines]\n+    return _unique_lines([line for line in normalized if line])\n+\n+\n+def _collect_action_labels(payload: Any) -> List[str]:\n+    labels: List[str] = []\n+    for item in _walk_nodes(payload):\n+        if not isinstance(item, dict):\n+            continue\n+        tag = str(item.get(\"tag\", \"\") or item.get(\"type\", \"\")).strip().lower()\n+        if tag not in {\"button\", \"select_static\", \"overflow\", \"date_picker\", \"picker\"}:\n+            continue\n+        label = _first_non_empty_text(\n+            item.get(\"text\"),\n+            item.get(\"name\"),\n+            item.get(\"value\"),\n+            _find_first_text(item, keys=(\"text\", \"content\", \"name\", \"value\")),\n+        )\n+        if label:\n+            labels.append(label)\n+    return _unique_lines(labels)\n+\n+\n+def _collect_text_segments(value: Any, *, in_rich_block: bool) -> List[str]:\n+    if isinstance(value, str):\n+        return [_normalize_feishu_text(value)] if in_rich_block else []\n+    if isinstance(value, list):\n+        segments: List[str] = []\n+        for item in value:\n+            segments.extend(_collect_text_segments(item, in_rich_block=in_rich_block))\n+        return segments\n+    if not isinstance(value, dict):\n+        return []\n+\n+    tag = str(value.get(\"tag\", \"\") or value.get(\"type\", \"\")).strip().lower()\n+    next_in_rich_block = in_rich_block or tag in {\n+        \"plain_text\",\n+        \"lark_md\",\n+        \"markdown\",\n+        \"note\",\n+        \"div\",\n+        \"column_set\",\n+        \"column\",\n+        \"action\",\n+        \"button\",\n+        \"select_static\",\n+        \"date_picker\",\n+    }\n+\n+    segments: List[str] = []\n+    for key in _SUPPORTED_CARD_TEXT_KEYS:\n+        item = value.get(key)\n+        if isinstance(item, str) and next_in_rich_block:\n+            normalized = _normalize_feishu_text(item)\n+            if normalized:\n+                segments.append(normalized)\n+\n+    for key, item in value.items():\n+        if key in _SKIP_TEXT_KEYS:\n+            continue\n+        segments.extend(_collect_text_segments(item, in_rich_block=next_in_rich_block))\n+    return segments\n+\n+\n+def _build_media_ref_from_payload(payload: Dict[str, Any], *, resource_type: str) -> FeishuPostMediaRef:\n+    file_key = str(payload.get(\"file_key\", \"\") or \"\").strip()\n+    file_name = _first_non_empty_text(\n+        payload.get(\"file_name\"),\n+        payload.get(\"title\"),\n+        payload.get(\"text\"),\n+    )\n+    effective_type = resource_type if resource_type in {\"audio\", \"video\"} else \"file\"\n+    return FeishuPostMediaRef(file_key=file_key, file_name=file_name, resource_type=effective_type)\n+\n+\n+def _attachment_placeholder(file_name: str) -> str:\n+    normalized_name = _normalize_feishu_text(file_name)\n+    return f\"[Attachment: {normalized_name}]\" if normalized_name else FALLBACK_ATTACHMENT_TEXT\n+\n+\n+def _find_header_title(payload: Any) -> str:\n+    if not isinstance(payload, dict):\n+        return \"\"\n+    header = payload.get(\"header\")\n+    if not isinstance(header, dict):\n+        return \"\"\n+    title = header.get(\"title\")\n+    if isinstance(title, dict):\n+        return _first_non_empty_text(title.get(\"content\"), title.get(\"text\"), title.get(\"name\"))\n+    return _normalize_feishu_text(str(title or \"\"))\n+\n+\n+def _find_first_text(payload: Any, *, keys: tuple[str, ...]) -> str:\n+    for node in _walk_nodes(payload):\n+        if not isinstance(node, dict):\n+            continue\n+        for key in keys:\n+            value = node.get(key)\n+            if isinstance(value, str):\n+                normalized = _normalize_feishu_text(value)\n+                if normalized:\n+                    return normalized\n+    return \"\"\n+\n+\n+def _walk_nodes(value: Any):\n+    if isinstance(value, dict):\n+        yield value\n+        for item in value.values():\n+            yield from _walk_nodes(item)\n+    elif isinstance(value, list):\n+        for item in value:\n+            yield from _walk_nodes(item)\n+\n+\n+def _first_non_empty_text(*values: Any) -> str:\n+    for value in values:\n+        if isinstance(value, str):\n+            normalized = _normalize_feishu_text(value)\n+            if normalized:\n+                return normalized\n+        elif value is not None and not isinstance(value, (dict, list)):\n+            normalized = _normalize_feishu_text(str(value))\n+            if normalized:\n+                return normalized\n+    return \"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# General text utilities\n+# ---------------------------------------------------------------------------\n+\n+\n+def _normalize_feishu_text(text: str) -> str:\n+    cleaned = _MENTION_PLACEHOLDER_RE.sub(\" \", text or \"\")\n+    cleaned = cleaned.replace(\"\\r\\n\", \"\\n\").replace(\"\\r\", \"\\n\")\n+    cleaned = \"\\n\".join(_WHITESPACE_RE.sub(\" \", line).strip() for line in cleaned.split(\"\\n\"))\n+    cleaned = \"\\n\".join(line for line in cleaned.split(\"\\n\") if line)\n+    cleaned = _MULTISPACE_RE.sub(\" \", cleaned)\n+    return cleaned.strip()\n+\n+\n+def _unique_lines(lines: List[str]) -> List[str]:\n+    seen: set[str] = set()\n+    unique: List[str] = []\n+    for line in lines:\n+        if not line or line in seen:\n+            continue\n+        seen.add(line)\n+        unique.append(line)\n+    return unique\n+\n+\n+def _run_official_feishu_ws_client(ws_client: Any) -> None:\n+    \"\"\"Run the official Lark WS client in its own thread-local event loop.\"\"\"\n+    import lark_oapi.ws.client as ws_client_module\n+\n+    loop = asyncio.new_event_loop()\n+    asyncio.set_event_loop(loop)\n+    ws_client_module.loop = loop\n+    ws_client.start()\n+\n+\n+def check_feishu_requirements() -> bool:\n+    \"\"\"Check if Feishu/Lark dependencies are available.\"\"\"\n+    return FEISHU_AVAILABLE\n+\n+\n+class FeishuAdapter(BasePlatformAdapter):\n+    \"\"\"Feishu/Lark bot adapter.\"\"\"\n+\n+    MAX_MESSAGE_LENGTH = 8000\n+\n+    # =========================================================================\n+    # Lifecycle — init / settings / connect / disconnect\n+    # =========================================================================\n+\n+    def __init__(self, config: PlatformConfig):\n+        super().__init__(config, Platform.FEISHU)\n+\n+        self._settings = self._load_settings(config.extra or {})\n+        self._apply_settings(self._settings)\n+        self._client: Optional[Any] = None\n+        self._ws_client: Optional[Any] = None\n+        self._ws_future: Optional[asyncio.Future] = None\n+        self._loop: Optional[asyncio.AbstractEventLoop] = None\n+        self._webhook_runner: Optional[Any] = None\n+        self._webhook_site: Optional[Any] = None\n+        self._event_handler = self._build_event_handler()\n+        self._seen_message_ids: Dict[str, float] = {}  # message_id → seen_at (time.time())\n+        self._seen_message_order: List[str] = []\n+        self._dedup_state_path = get_hermes_home() / \"feishu_seen_message_ids.json\"\n+        self._dedup_lock = threading.Lock()\n+        self._sender_name_cache: Dict[str, tuple[str, float]] = {}  # sender_id → (name, expire_at)\n+        self._webhook_rate_counts: Dict[str, tuple[int, float]] = {}  # rate_key → (count, window_start)\n+        self._webhook_anomaly_counts: Dict[str, tuple[int, str, float]] = {}  # ip → (count, last_status, first_seen)\n+        self._card_action_tokens: [redacted:secret-assignment] float] = {}  # token → first_seen_time\n+        self._chat_locks: Dict[str, asyncio.Lock] = {}  # chat_id → lock (per-chat serial processing)\n+        self._sent_message_ids_to_chat: Dict[str, str] = {}  # message_id → chat_id (for reaction routing)\n+        self._sent_message_id_order: List[str] = []  # LRU order for _sent_message_ids_to_chat\n+        self._chat_info_cache: Dict[str, Dict[str, Any]] = {}\n+        self._message_text_cache: Dict[str, Optional[str]] = {}\n+        self._app_lock_identity: Optional[str] = None\n+        self._text_batch_state = FeishuBatchState()\n+        self._pending_text_batches = self._text_batch_state.events\n+        self._pending_text_batch_tasks = self._text_batch_state.tasks\n+        self._pending_text_batch_counts = self._text_batch_state.counts\n+        self._media_batch_state = FeishuBatchState()\n+        self._pending_media_batches = self._media_batch_state.events\n+        self._pending_media_batch_tasks = self._media_batch_state.tasks\n+        self._load_seen_message_ids()\n+\n+    @staticmethod\n+    def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:\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(),\n+            domain_name=str(extra.get(\"domain\") or os.getenv(\"FEISHU_DOMAIN\", \"feishu\")).strip().lower(),\n+            connection_mode=str(\n+                extra.get(\"connection_mode\") or os.getenv(\"FEISHU_CONNECTION_MODE\", \"websocket\")\n+            ).strip().lower(),\n+            encrypt_key=os.getenv(\"FEISHU_ENCRYPT_KEY\", \"\").strip(),\n+            verification_token=[redacted:secret-assignment]\"FEISHU_VERIFICATION_TOKEN\", \"\").strip(),\n+            group_policy=os.getenv(\"FEISHU_GROUP_POLICY\", \"allowlist\").strip().lower(),\n+            allowed_group_users=frozenset(\n+                item.strip()\n+                for item in os.getenv(\"FEISHU_ALLOWED_USERS\", \"\").split(\",\")\n+                if item.strip()\n+            ),\n+            bot_open_id=os.getenv(\"FEISHU_BOT_OPEN_ID\", \"\").strip(),\n+            bot_user_id=os.getenv(\"FEISHU_BOT_USER_ID\", \"\").strip(),\n+            bot_name=os.getenv(\"FEISHU_BOT_NAME\", \"\").strip(),\n+            dedup_cache_size=max(\n+                32,\n+                int(os.getenv(\"HERMES_FEISHU_DEDUP_CACHE_SIZE\", str(_DEFAULT_DEDUP_CACHE_SIZE))),\n+            ),\n+            text_batch_delay_seconds=float(\n+                os.getenv(\"HERMES_FEISHU_TEXT_BATCH_DELAY_SECONDS\", str(_DEFAULT_TEXT_BATCH_DELAY_SECONDS))\n+            ),\n+            text_batch_max_messages=max(\n+                1,\n+                int(os.getenv(\"HERMES_FEISHU_TEXT_BATCH_MAX_MESSAGES\", str(_DEFAULT_TEXT_BATCH_MAX_MESSAGES))),\n+            ),\n+            text_batch_max_chars=max(\n+                1,\n+                int(os.getenv(\"HERMES_FEISHU_TEXT_BATCH_MAX_CHARS\", str(_DEFAULT_TEXT_BATCH_MAX_CHARS))),\n+            ),\n+            media_batch_delay_seconds=float(\n+                os.getenv(\"HERMES_FEISHU_MEDIA_BATCH_DELAY_SECONDS\", str(_DEFAULT_MEDIA_BATCH_DELAY_SECONDS))\n+            ),\n+            webhook_host=str(\n+                extra.get(\"webhook_host\") or os.getenv(\"FEISHU_WEBHOOK_HOST\", _DEFAULT_WEBHOOK_HOST)\n+            ).strip(),\n+            webhook_port=int(\n+                extra.get(\"webhook_port\") or os.getenv(\"FEISHU_WEBHOOK_PORT\", str(_DEFAULT_WEBHOOK_PORT))\n+            ),\n+            webhook_path=(\n+                str(extra.get(\"webhook_path\") or os.getenv(\"FEISHU_WEBHOOK_PATH\", _DEFAULT_WEBHOOK_PATH)).strip()\n+                or _DEFAULT_WEBHOOK_PATH\n+            ),\n+        )\n+\n+    def _apply_settings(self, settings: FeishuAdapterSettings) -> None:\n+        self._app_id = settings.app_id\n+        self._app_secret = [redacted:secret-assignment]\n+        self._domain_name = settings.domain_name\n+        self._connection_mode = settings.connection_mode\n+        self._encrypt_key = settings.encrypt_key\n+        self._verification_token = [redacted:secret-assignment]\n+        self._group_policy = settings.group_policy\n+        self._allowed_group_users = set(settings.allowed_group_users)\n+        self._bot_open_id = settings.bot_open_id\n+        self._bot_user_id = settings.bot_user_id\n+        self._bot_name = settings.bot_name\n+        self._dedup_cache_size = settings.dedup_cache_size\n+        self._text_batch_delay_seconds = settings.text_batch_delay_seconds\n+        self._text_batch_max_messages = settings.text_batch_max_messages\n+        self._text_batch_max_chars = settings.text_batch_max_chars\n+        self._media_batch_delay_seconds = settings.media_batch_delay_seconds\n+        self._webhook_host = settings.webhook_host\n+        self._webhook_port = settings.webhook_port\n+        self._webhook_path = settings.webhook_path\n+\n+    def _build_event_handler(self) -> Any:\n+        if EventDispatcherHandler is None:\n+            return None\n+        return (\n+            EventDispatcherHandler.builder(\n+                self._encrypt_key,\n+                self._verification_token,\n+            )\n+            .register_p2_im_message_message_read_v1(self._on_message_read_event)\n+            .register_p2_im_message_receive_v1(self._on_message_event)\n+            .register_p2_im_message_reaction_created_v1(\n+                lambda data: self._on_reaction_event(\"im.message.reaction.created_v1\", data)\n+            )\n+            .register_p2_im_message_reaction_deleted_v1(\n+                lambda data: self._on_reaction_event(\"im.message.reaction.deleted_v1\", data)\n+            )\n+            .register_p2_card_action_trigger(self._on_card_action_trigger)\n+            .build()\n+        )\n+\n+    async def connect(self) -> bool:\n+        \"\"\"Connect to Feishu/Lark.\"\"\"\n+        if not FEISHU_AVAILABLE:\n+            logger.error(\"[Feishu] lark-oapi not installed\")\n+            return False\n+        if not self._app_id or not self._app_secret:\n+            logger.error(\"[Feishu] FEISHU_APP_ID or FEISHU_APP_SECRET not set\")\n+            return False\n+        if self._connection_mode not in {\"websocket\", \"webhook\"}:\n+            logger.error(\n+                \"[Feishu] Unsupported FEISHU_CONNECTION_MODE=%s. Supported modes: websocket, webhook.\",\n+                self._connection_mode,\n+            )\n+            return False\n+\n+        try:\n+            self._app_lock_identity = self._app_id\n+            acquired, existing = acquire_scoped_lock(\n+                _FEISHU_APP_LOCK_SCOPE,\n+                self._app_lock_identity,\n+                metadata={\"platform\": self.platform.value},\n+            )\n+            if not acquired:\n+                owner_pid = existing.get(\"pid\") if isinstance(existing, dict) else None\n+                message = (\n+                    \"Another local Hermes gateway is already using this Feishu app_id\"\n+                    + (f\" (PID {owner_pid}).\" if owner_pid else \".\")\n+                    + \" Stop the other gateway before starting a second Feishu websocket client.\"\n+                )\n+                logger.error(\"[Feishu] %s\", message)\n+                self._set_fatal_error(\"feishu_app_lock\", message, retryable=False)\n+                return False\n+\n+            self._loop = asyncio.get_running_loop()\n+            await self._connect_with_retry()\n+            self._mark_connected()\n+            logger.info(\"[Feishu] Connected in %s mode (%s)\", self._connection_mode, self._domain_name)\n+            return True\n+        except Exception as exc:\n+            await self._release_app_lock()\n+            message = f\"Feishu startup failed: {exc}\"\n+            self._set_fatal_error(\"feishu_connect_error\", message, retryable=True)\n+            logger.error(\"[Feishu] Failed to connect: %s\", exc, exc_info=True)\n+            return False\n+\n+    async def disconnect(self) -> None:\n+        \"\"\"Disconnect from Feishu/Lark.\"\"\"\n+        self._running = False\n+        await self._cancel_pending_tasks(self._pending_text_batch_tasks)\n+        await self._cancel_pending_tasks(self._pending_media_batch_tasks)\n+        self._reset_batch_buffers()\n+        self._disable_websocket_auto_reconnect()\n+        await self._stop_webhook_server()\n+        self._ws_future = None\n+        self._loop = None\n+        self._persist_seen_message_ids()\n+        await self._release_app_lock()\n+\n+        self._mark_disconnected()\n+        logger.info(\"[Feishu] Disconnected\")\n+\n+    async def _cancel_pending_tasks(self, tasks: Dict[str, asyncio.Task]) -> None:\n+        pending = [task for task in tasks.values() if task and not task.done()]\n+        for task in pending:\n+            task.cancel()\n+        if pending:\n+            await asyncio.gather(*pending, return_exceptions=True)\n+        tasks.clear()\n+\n+    def _reset_batch_buffers(self) -> None:\n+        self._pending_text_batches.clear()\n+        self._pending_text_batch_counts.clear()\n+        self._pending_media_batches.clear()\n+\n+    def _disable_websocket_auto_reconnect(self) -> None:\n+        if self._ws_client is None:\n+            return\n+        try:\n+            setattr(self._ws_client, \"_auto_reconnect\", False)\n+        except Exception:\n+            pass\n+        finally:\n+            self._ws_client = None\n+\n+    async def _stop_webhook_server(self) -> None:\n+        if self._webhook_runner is None:\n+            return\n+        try:\n+            await self._webhook_runner.cleanup()\n+        finally:\n+            self._webhook_runner = None\n+            self._webhook_site = None\n+\n+    # =========================================================================\n+    # Outbound — send / edit / send_image / send_voice / …\n+    # =========================================================================\n+\n+    async def send(\n+        self,\n+        chat_id: str,\n+        content: str,\n+        reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> SendResult:\n+        \"\"\"Send a Feishu message.\"\"\"\n+        if not self._client:\n+            return SendResult(success=False, error=\"Not connected\")\n+\n+        formatted = self.format_message(content)\n+        chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)\n+        last_response = None\n+\n+        try:\n+            for chunk in chunks:\n+                msg_type, payload = self._build_outbound_payload(chunk)\n+                try:\n+                    response = await self._feishu_send_with_retry(\n+                        chat_id=chat_id,\n+                        msg_type=msg_type,\n+                        payload=payload,\n+                        reply_to=reply_to,\n+                        metadata=metadata,\n+                    )\n+                except Exception as exc:\n+                    if msg_type != \"post\" or not _POST_CONTENT_INVALID_RE.search(str(exc)):\n+                        raise\n+                    logger.warning(\"[Feishu] Invalid post payload rejected by API; falling back to plain text\")\n+                    response = await self._feishu_send_with_retry(\n+                        chat_id=chat_id,\n+                        msg_type=\"text\",\n+                        payload=json.dumps({\"text\": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False),\n+                        reply_to=reply_to,\n+                        metadata=metadata,\n+                    )\n+                if (\n+                    msg_type == \"post\"\n+                    and not self._response_succeeded(response)\n+                    and _POST_CONTENT_INVALID_RE.search(str(getattr(response, \"msg\", \"\") or \"\"))\n+                ):\n+                    logger.warning(\"[Feishu] Post payload rejected by API response; falling back to plain text\")\n+                    response = await self._feishu_send_with_retry(\n+                        chat_id=chat_id,\n+                        msg_type=\"text\",\n+                        payload=json.dumps({\"text\": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False),\n+                        reply_to=reply_to,\n+                        metadata=metadata,\n+                    )\n+                last_response = response\n+\n+            return self._finalize_send_result(last_response, \"send failed\")\n+        except Exception as exc:\n+            logger.error(\"[Feishu] Send error: %s\", exc, exc_info=True)\n+            return SendResult(success=False, error=str(exc))\n+\n+    async def edit_message(\n+        self,\n+        chat_id: str,\n+        message_id: str,\n+        content: str,\n+    ) -> SendResult:\n+        \"\"\"Edit a previously sent Feishu text/post message.\"\"\"\n+        if not self._client:\n+            return SendResult(success=False, error=\"Not connected\")\n+\n+        try:\n+            msg_type, payload = self._build_outbound_payload(content)\n+            body = self._build_update_message_body(msg_type=msg_type, content=payload)\n+            request = self._build_update_message_request(message_id=message_id, request_body=body)\n+            response = await asyncio.to_thread(self._client.im.v1.message.update, request)\n+            result = self._finalize_send_result(response, \"update failed\")\n+            if not result.success and msg_type == \"post\" and _POST_CONTENT_INVALID_RE.search(result.error or \"\"):\n+                logger.warning(\"[Feishu] Invalid post update payload rejected by API; falling back to plain text\")\n+                fallback_body = self._build_update_message_body(\n+                    msg_type=\"text\",\n+                    content=json.dumps({\"text\": _strip_markdown_to_plain_text(content)}, ensure_ascii=False),\n+                )\n+                fallback_request = self._build_update_message_request(message_id=message_id, request_body=fallback_body)\n+                fallback_response = await asyncio.to_thread(self._client.im.v1.message.update, fallback_request)\n+                result = self._finalize_send_result(fallback_response, \"update failed\")\n+            if result.success:\n+                result.message_id = message_id\n+            return result\n+        except Exception as exc:\n+            logger.error(\"[Feishu] Failed to edit message %s: %s\", message_id, exc, exc_info=True)\n+            return SendResult(success=False, error=str(exc))\n+\n+    async def send_voice(\n+        self,\n+        chat_id: str,\n+        audio_path: str,\n+        caption: Optional[str] = None,\n+        reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n+        **kwargs,\n+    ) -> SendResult:\n+        \"\"\"Send audio to Feishu as a file attachment plus optional caption.\"\"\"\n+        return await self._send_uploaded_file_message(\n+            chat_id=chat_id,\n+            file_path=audio_path,\n+            reply_to=reply_to,\n+            metadata=metadata,\n+            caption=caption,\n+            outbound_message_type=\"audio\",\n+        )\n+\n+    async def send_document(\n+        self,\n+        chat_id: str,\n+        file_path: str,\n+        caption: Optional[str] = None,\n+        file_name: Optional[str] = None,\n+        reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n+        **kwargs,\n+    ) -> SendResult:\n+        \"\"\"Send a document/file attachment to Feishu.\"\"\"\n+        return await self._send_uploaded_file_message(\n+            chat_id=chat_id,\n+            file_path=file_path,\n+            reply_to=reply_to,\n+            metadata=metadata,\n+            caption=caption,\n+            file_name=file_name,\n+        )\n+\n+    async def send_video(\n+        self,\n+        chat_id: str,\n+        video_path: str,\n+        caption: Optional[str] = None,\n+        reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n+        **kwargs,\n+    ) -> SendResult:\n+        \"\"\"Send a video file to Feishu.\"\"\"\n+        return await self._send_uploaded_file_message(\n+            chat_id=chat_id,\n+            file_path=video_path,\n+            reply_to=reply_to,\n+            metadata=metadata,\n+            caption=caption,\n+            outbound_message_type=\"media\",\n+        )\n+\n+    async def send_image_file(\n+        self,\n+        chat_id: str,\n+        image_path: str,\n+        caption: Optional[str] = None,\n+        reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n+        **kwargs,\n+    ) -> SendResult:\n+        \"\"\"Send a local image file to Feishu.\"\"\"\n+        if not self._client:\n+            return SendResult(success=False, error=\"Not connected\")\n+        if not os.path.exists(image_path):\n+            return SendResult(success=False, error=f\"Image file not found: {image_path}\")\n+\n+        try:\n+            with open(image_path, \"rb\") as image_file:\n+                body = self._build_image_upload_body(\n+                    image_type=_FEISHU_IMAGE_UPLOAD_TYPE,\n+                    image=image_file,\n+                )\n+                request = self._build_image_upload_request(body)\n+                upload_response = await asyncio.to_thread(self._client.im.v1.image.create, request)\n+            image_key = self._extract_response_field(upload_response, \"image_key\")\n+            if not image_key:\n+                return self._response_error_result(\n+                    upload_response,\n+                    default_message=\"image upload failed\",\n+                    override_error=\"Feishu image upload missing image_key\",\n+                )\n+\n+            if caption:\n+                post_payload = self._build_media_post_payload(\n+                    caption=caption,\n+                    media_tag={\"tag\": \"img\", \"image_key\": image_key},\n+                )\n+                message_response = await self._feishu_send_with_retry(\n+                    chat_id=chat_id,\n+                    msg_type=\"post\",\n+                    payload=post_payload,\n+                    reply_to=reply_to,\n+                    metadata=metadata,\n+                )\n+            else:\n+                message_response = await self._feishu_send_with_retry(\n+                    chat_id=chat_id,\n+                    msg_type=\"image\",\n+                    payload=json.dumps({\"image_key\": image_key}, ensure_ascii=False),\n+                    reply_to=reply_to,\n+                    metadata=metadata,\n+                )\n+            return self._finalize_send_result(message_response, \"image send failed\")\n+        except Exception as exc:\n+            logger.error(\"[Feishu] Failed to send image %s: %s\", image_path, exc, exc_info=True)\n+            return SendResult(success=False, error=str(exc))\n+\n+    async def send_typing(self, chat_id: str, metadata=None) -> None:\n+        \"\"\"Feishu bot API does not expose a typing indicator.\"\"\"\n+        return None\n+\n+    async def send_image(\n+        self,\n+        chat_id: str,\n+        image_url: str,\n+        caption: Optional[str] = None,\n+        reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> SendResult:\n+        \"\"\"Download a remote image then send it through the native Feishu image flow.\"\"\"\n+        try:\n+            image_path = await self._download_remote_image(image_url)\n+        except Exception as exc:\n+            logger.error(\"[Feishu] Failed to download image %s: %s\", image_url, exc, exc_info=True)\n+            return await super().send_image(\n+                chat_id=chat_id,\n+                image_url=image_url,\n+                caption=caption,\n+                reply_to=reply_to,\n+                metadata=metadata,\n+            )\n+        return await self.send_image_file(\n+            chat_id=chat_id,\n+            image_path=image_path,\n+            caption=caption,\n+            reply_to=reply_to,\n+            metadata=metadata,\n+        )\n+\n+    async def send_animation(\n+        self,\n+        chat_id: str,\n+        animation_url: str,\n+        caption: Optional[str] = None,\n+        reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> SendResult:\n+        \"\"\"Feishu has no native GIF bubble; degrade to a downloadable file.\"\"\"\n+        try:\n+            file_path, file_name = await self._download_remote_document(\n+                animation_url,\n+                default_ext=\".gif\",\n+                preferred_name=\"animation.gif\",\n+            )\n+        except Exception as exc:\n+            logger.error(\"[Feishu] Failed to download animation %s: %s\", animation_url, exc, exc_info=True)\n+            return await super().send_animation(\n+                chat_id=chat_id,\n+                animation_url=animation_url,\n+                caption=caption,\n+                reply_to=reply_to,\n+                metadata=metadata,\n+            )\n+        degraded_caption = f\"[GIF downgraded to file]\\n{caption}\" if caption else \"[GIF downgraded to file]\"\n+        return await self.send_document(\n+            chat_id=chat_id,\n+            file_path=file_path,\n+            file_name=file_name,\n+            caption=degraded_caption,\n+            reply_to=reply_to,\n+            metadata=metadata,\n+        )\n+\n+    async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:\n+        \"\"\"Return real chat metadata from Feishu when available.\"\"\"\n+        fallback = {\n+            \"chat_id\": chat_id,\n+            \"name\": chat_id,\n+            \"type\": \"dm\",\n+        }\n+        if not self._client:\n+            return fallback\n+\n+        cached = self._chat_info_cache.get(chat_id)\n+        if cached is not None:\n+            return dict(cached)\n+\n+        try:\n+            request = self._build_get_chat_request(chat_id)\n+            response = await asyncio.to_thread(self._client.im.v1.chat.get, request)\n+            if not response or getattr(response, \"success\", lambda: False)() is False:\n+                code = getattr(response, \"code\", \"unknown\")\n+                msg = getattr(response, \"msg\", \"chat lookup failed\")\n+                logger.warning(\"[Feishu] Failed to get chat info for %s: [%s] %s\", chat_id, code, msg)\n+                return fallback\n+\n+            data = getattr(response, \"data\", None)\n+            raw_chat_type = str(getattr(data, \"chat_type\", \"\") or \"\").strip().lower()\n+            info = {\n+                \"chat_id\": chat_id,\n+                \"name\": str(getattr(data, \"name\", None) or chat_id),\n+                \"type\": self._map_chat_type(raw_chat_type),\n+                \"raw_type\": raw_chat_type or None,\n+            }\n+            self._chat_info_cache[chat_id] = info\n+            return dict(info)\n+        except Exception:\n+            logger.warning(\"[Feishu] Failed to get chat info for %s\", chat_id, exc_info=True)\n+            return fallback\n+\n+    def format_message(self, content: str) -> str:\n+        \"\"\"Feishu text messages are plain text by default.\"\"\"\n+        return content.strip()\n+\n+    # =========================================================================\n+    # Inbound event handlers\n+    # =========================================================================\n+\n+    def _on_message_event(self, data: Any) -> None:\n+        \"\"\"Normalize Feishu inbound events into MessageEvent.\"\"\"\n+        if self._loop is None:\n+            logger.warning(\"[Feishu] Dropping inbound message before adapter loop is ready\")\n+            return\n+        future = asyncio.run_coroutine_threadsafe(\n+            self._handle_message_event_data(data),\n+            self._loop,\n+        )\n+        future.add_done_callback(self._log_background_failure)\n+\n+    async def _handle_message_event_data(self, data: Any) -> None:\n+        \"\"\"Shared inbound message handling for websocket and webhook transports.\"\"\"\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+            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 getattr(sender, \"sender_type\", \"\") == \"bot\":\n+            logger.debug(\"[Feishu] Dropping bot-originated event: %s\", message_id)\n+            return\n+\n+        chat_type = getattr(message, \"chat_type\", \"p2p\")\n+        if chat_type != \"p2p\" and not self._should_accept_group_message(message, sender_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+            chat_type=chat_type,\n+            message_id=message_id,\n+        )\n+\n+    def _on_message_read_event(self, data: P2ImMessageMessageReadV1) -> None:\n+        \"\"\"Ignore read-receipt events that Hermes does not act on.\"\"\"\n+        event = getattr(data, \"event\", None)\n+        message = getattr(event, \"message\", None)\n+        message_id = getattr(message, \"message_id\", None) or \"\"\n+        logger.debug(\"[Feishu] Ignoring message_read event: %s\", message_id)\n+\n+    def _on_bot_added_to_chat(self, data: Any) -> None:\n+        \"\"\"Handle bot being added to a group chat.\"\"\"\n+        event = getattr(data, \"event\", None)\n+        chat_id = str(getattr(event, \"chat_id\", \"\") or \"\")\n+        logger.info(\"[Feishu] Bot added to chat: %s\", chat_id)\n+        self._chat_info_cache.pop(chat_id, None)\n+\n+    def _on_bot_removed_from_chat(self, data: Any) -> None:\n+        \"\"\"Handle bot being removed from a group chat.\"\"\"\n+        event = getattr(data, \"event\", None)\n+        chat_id = str(getattr(event, \"chat_id\", \"\") or \"\")\n+        logger.info(\"[Feishu] Bot removed from chat: %s\", chat_id)\n+        self._chat_info_cache.pop(chat_id, None)\n+\n+    def _on_reaction_event(self, event_type: str, data: Any) -> None:\n+        \"\"\"Route user reactions on bot messages as synthetic text events.\"\"\"\n+        event = getattr(data, \"event\", None)\n+        message_id = str(getattr(event, \"message_id\", \"\") or \"\")\n+        operator_type = str(getattr(event, \"operator_type\", \"\") or \"\")\n+        reaction_type_obj = getattr(event, \"reaction_type\", None)\n+        emoji_type = str(getattr(reaction_type_obj, \"emoji_type\", \"\") or \"\")\n+        action = \"added\" if \"created\" in event_type else \"removed\"\n+        logger.debug(\n+            \"[Feishu] Reaction %s on message %s (operator_type=%s, emoji=%s)\",\n+            action,\n+            message_id,\n+            operator_type,\n+            emoji_type,\n+        )\n+        # Only process reactions from real users. Ignore app/bot-generated reactions\n+        # and Hermes' own ACK emoji to avoid feedback loops.\n+        if (\n+            operator_type in {\"bot\", \"app\"}\n+            or emoji_type == _FEISHU_ACK_EMOJI\n+            or not message_id\n+            or self._loop is None\n+        ):\n+            return\n+        future = asyncio.run_coroutine_threadsafe(\n+            self._handle_reaction_event(event_type, data),\n+            self._loop,\n+        )\n+        future.add_done_callback(self._log_background_failure)\n+\n+    def _on_card_action_trigger(self, data: Any) -> Any:\n+        \"\"\"Schedule Feishu card actions on the adapter loop and acknowledge immediately.\"\"\"\n+        if self._loop is None:\n+            logger.warning(\"[Feishu] Dropping card action before adapter loop is ready\")\n+        else:\n+            future = asyncio.run_coroutine_threadsafe(\n+                self._handle_card_action_event(data),\n+                self._loop,\n+            )\n+            future.add_done_callback(self._log_background_failure)\n+        if P2CardActionTriggerResponse is None:\n+            return None\n+        return P2CardActionTriggerResponse()\n+\n+    async def _handle_reaction_event(self, event_type: str, data: Any) -> None:\n+        \"\"\"Fetch the reacted-to message; if it was sent by this bot, emit a synthetic text event.\"\"\"\n+        if not self._client:\n+            return\n+        event = getattr(data, \"event\", None)\n+        message_id = str(getattr(event, \"message_id\", \"\") or \"\")\n+        if not message_id:\n+            return\n+\n+        # Fetch the target message to verify it was sent by us and to obtain chat context.\n+        try:\n+            request = self._build_get_message_request(message_id)\n+            response = await asyncio.to_thread(self._client.im.v1.message.get, request)\n+            if not response or not getattr(response, \"success\", lambda: False)():\n+                return\n+            items = getattr(getattr(response, \"data\", None), \"items\", None) or []\n+            msg = items[0] if items else None\n+            if not msg:\n+                return\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+            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:\n+                return\n+        except Exception:\n+            logger.debug(\"[Feishu] Failed to fetch message for reaction routing\", exc_info=True)\n+            return\n+\n+        user_id_obj = getattr(event, \"user_id\", None)\n+        reaction_type_obj = getattr(event, \"reaction_type\", None)\n+        emoji_type = str(getattr(reaction_type_obj, \"emoji_type\", \"\") or \"UNKNOWN\")\n+        action = \"added\" if \"created\" in event_type else \"removed\"\n+        synthetic_text = f\"reaction:{action}:{emoji_type}\"\n+\n+        sender_profile = await self._resolve_sender_profile(user_id_obj)\n+        chat_info = await self.get_chat_info(chat_id)\n+        source = self.build_source(\n+            chat_id=chat_id,\n+            chat_name=chat_info.get(\"name\") or chat_id or \"Feishu Chat\",\n+            chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type=chat_type_raw),\n+            user_id=sender_profile[\"user_id\"],\n+            user_name=sender_profile[\"user_name\"],\n+            thread_id=None,\n+            user_id_alt=sender_profile[\"user_id_alt\"],\n+        )\n+        synthetic_event = MessageEvent(\n+            text=synthetic_text,\n+            message_type=MessageType.TEXT,\n+            source=source,\n+            raw_message=data,\n+            message_id=message_id,\n+            timestamp=datetime.now(),\n+        )\n+        logger.info(\"[Feishu] Routing reaction %s:%s on bot message %s as synthetic event\", action, emoji_type, message_id)\n+        await self._handle_message_with_guards(synthetic_event)\n+\n+    def _is_card_action_duplicate(self, token: str) -> bool:\n+        \"\"\"Return True if this card action token was already processed within the dedup window.\"\"\"\n+        now = time.time()\n+        # Prune expired tokens lazily each call.\n+        expired = [t for t, ts in self._card_action_tokens.items() if now - ts > _FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS]\n+        for t in expired:\n+            del self._card_action_tokens[t]\n+        if token in self._card_action_tokens:\n+            return True\n+        self._card_action_tokens[token] = now\n+        return False\n+\n+    async def _handle_card_action_event(self, data: Any) -> None:\n+        \"\"\"Route Feishu interactive card button clicks as synthetic COMMAND events.\"\"\"\n+        event = getattr(data, \"event\", None)\n+        token = [redacted:secret-assignment] \"token\", \"\") or \"\")\n+        if token and self._is_card_action_duplicate(token):\n+            logger.debug(\"[Feishu] Dropping duplicate card action token: %s\", token)\n+            return\n+\n+        context = getattr(event, \"context\", None)\n+        chat_id = str(getattr(context, \"open_chat_id\", \"\") or \"\")\n+        operator = getattr(event, \"operator\", None)\n+        open_id = str(getattr(operator, \"open_id\", \"\") or \"\")\n+        if not chat_id or not open_id:\n+            logger.debug(\"[Feishu] Card action missing chat_id or operator open_id, dropping\")\n+            return\n+\n+        action = getattr(event, \"action\", None)\n+        action_tag = str(getattr(action, \"tag\", \"\") or \"button\")\n+        action_value = getattr(action, \"value\", {}) or {}\n+        synthetic_text = f\"/card {action_tag}\"\n+        if action_value:\n+            try:\n+                synthetic_text += f\" {json.dumps(action_value, ensure_ascii=False)}\"\n+            except Exception:\n+                pass\n+\n+        sender_id = SimpleNamespace(open_id=open_id, user_id=None, union_id=None)\n+        sender_profile = await self._resolve_sender_profile(sender_id)\n+        chat_info = await self.get_chat_info(chat_id)\n+        source = self.build_source(\n+            chat_id=chat_id,\n+            chat_name=chat_info.get(\"name\") or chat_id or \"Feishu Chat\",\n+            chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type=\"group\"),\n+            user_id=sender_profile[\"user_id\"],\n+            user_name=sender_profile[\"user_name\"],\n+            thread_id=None,\n+            user_id_alt=sender_profile[\"user_id_alt\"],\n+        )\n+        synthetic_event = MessageEvent(\n+            text=synthetic_text,\n+            message_type=MessageType.COMMAND,\n+            source=source,\n+            raw_message=data,\n+            message_id=token or str(uuid.uuid4()),\n+            timestamp=datetime.now(),\n+        )\n+        logger.info(\"[Feishu] Routing card action %r from %s in %s as synthetic command\", action_tag, open_id, chat_id)\n+        await self._handle_message_with_guards(synthetic_event)\n+\n+    # =========================================================================\n+    # Per-chat serialization and typing indicator\n+    # =========================================================================\n+\n+    def _get_chat_lock(self, chat_id: str) -> asyncio.Lock:\n+        \"\"\"Return (creating if needed) the per-chat asyncio.Lock for serial message processing.\"\"\"\n+        lock = self._chat_locks.get(chat_id)\n+        if lock is None:\n+            lock = asyncio.Lock()\n+            self._chat_locks[chat_id] = lock\n+        return lock\n+\n+    async def _handle_message_with_guards(self, event: MessageEvent) -> None:\n+        \"\"\"Dispatch a single event through the agent pipeline with per-chat serialization\n+        and a persistent ACK emoji reaction before processing starts.\n+\n+        - Per-chat lock: ensures messages in the same chat are processed one at a time\n+          (matches openclaw's createChatQueue serial queue behaviour).\n+        - ACK indicator: adds a CHECK reaction to the triggering message before handing\n+          off to the agent and leaves it in place as a receipt marker.\n+        \"\"\"\n+        chat_id = getattr(event.source, \"chat_id\", \"\") or \"\" if event.source else \"\"\n+        chat_lock = self._get_chat_lock(chat_id)\n+        async with chat_lock:\n+            message_id = event.message_id\n+            if message_id:\n+                await self._add_ack_reaction(message_id)\n+            await self.handle_message(event)\n+\n+    async def _add_ack_reaction(self, message_id: str) -> Optional[str]:\n+        \"\"\"Add a persistent ACK emoji reaction to signal the message was received.\"\"\"\n+        if not self._client or not message_id:\n+            return None\n+        try:\n+            from lark_oapi.api.im.v1 import (  # lazy import — keeps optional dep optional\n+                CreateMessageReactionRequest,\n+                CreateMessageReactionRequestBody,\n+            )\n+            body = (\n+                CreateMessageReactionRequestBody.builder()\n+                .reaction_type({\"emoji_type\": _FEISHU_ACK_EMOJI})\n+                .build()\n+            )\n+            request = (\n+                CreateMessageReactionRequest.builder()\n+                .message_id(message_id)\n+                .request_body(body)\n+                .build()\n+            )\n+            response = await asyncio.to_thread(self._client.im.v1.message_reaction.create, request)\n+            if response and getattr(response, \"success\", lambda: False)():\n+                data = getattr(response, \"data\", None)\n+                return getattr(data, \"reaction_id\", None)\n+            logger.warning(\n+                \"[Feishu] Failed to add ack reaction to %s: code=%s msg=%s\",\n+                message_id,\n+                getattr(response, \"code\", None),\n+                getattr(response, \"msg\", None),\n+            )\n+        except Exception:\n+            logger.warning(\"[Feishu] Failed to add ack reaction to %s\", message_id, exc_info=True)\n+        return None\n+\n+    # =========================================================================\n+    # Webhook server and security\n+    # =========================================================================\n+\n+    def _record_webhook_anomaly(self, remote_ip: str, status: str) -> None:\n+        \"\"\"Increment the anomaly counter for remote_ip and emit a WARNING every threshold hits.\n+\n+        Mirrors openclaw's createWebhookAnomalyTracker: TTL 6 hours, log every 25 consecutive\n+        error responses from the same IP.\n+        \"\"\"\n+        now = time.time()\n+        entry = self._webhook_anomaly_counts.get(remote_ip)\n+        if entry is not None:\n+            count, _last_status, first_seen = entry\n+            if now - first_seen < _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS:\n+                count += 1\n+                if count % _FEISHU_WEBHOOK_ANOMALY_THRESHOLD == 0:\n+                    logger.warning(\n+                        \"[Feishu] Webhook anomaly: %d consecutive error responses (%s) from %s \"\n+                        \"over the last %.0fs\",\n+                        count,\n+                        status,\n+                        remote_ip,\n+                        now - first_seen,\n+                    )\n+                self._webhook_anomaly_counts[remote_ip] = (count, status, first_seen)\n+                return\n+        # Either first occurrence or TTL expired — start fresh.\n+        self._webhook_anomaly_counts[remote_ip] = (1, status, now)\n+\n+    def _clear_webhook_anomaly(self, remote_ip: str) -> None:\n+        \"\"\"Reset the anomaly counter for remote_ip after a successful request.\"\"\"\n+        self._webhook_anomaly_counts.pop(remote_ip, None)\n+\n+    # =========================================================================\n+    # Inbound processing pipeline\n+    # =========================================================================\n+\n+    async def _process_inbound_message(\n+        self,\n+        *,\n+        data: Any,\n+        message: Any,\n+        sender_id: Any,\n+        chat_type: str,\n+        message_id: str,\n+    ) -> None:\n+        text, inbound_type, media_urls, media_types = await self._extract_message_content(message)\n+        if inbound_type == MessageType.TEXT and not text and not media_urls:\n+            logger.debug(\"[Feishu] Ignoring unsupported or empty message type: %s\", getattr(message, \"message_type\", \"\"))\n+            return\n+\n+        if inbound_type == MessageType.TEXT and text.startswith(\"/\"):\n+            inbound_type = MessageType.COMMAND\n+\n+        reply_to_message_id = (\n+            getattr(message, \"parent_id\", None)\n+            or getattr(message, \"upper_message_id\", None)\n+            or None\n+        )\n+        reply_to_text = await self._fetch_message_text(reply_to_message_id) if reply_to_message_id else None\n+\n+        logger.info(\n+            \"[Feishu] Inbound %s message received: id=%s type=%s chat_id=%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+            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+        source = self.build_source(\n+            chat_id=chat_id,\n+            chat_name=chat_info.get(\"name\") or chat_id or \"Feishu Chat\",\n+            chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type=chat_type),\n+            user_id=sender_profile[\"user_id\"],\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+        )\n+        normalized = MessageEvent(\n+            text=text,\n+            message_type=inbound_type,\n+            source=source,\n+            raw_message=data,\n+            message_id=message_id,\n+            media_urls=media_urls,\n+            media_types=media_types,\n+            reply_to_message_id=reply_to_message_id,\n+            reply_to_text=reply_to_text,\n+            timestamp=datetime.now(),\n+        )\n+        await self._dispatch_inbound_event(normalized)\n+\n+    async def _dispatch_inbound_event(self, event: MessageEvent) -> None:\n+        \"\"\"Apply Feishu-specific burst protection before entering the base adapter.\"\"\"\n+        if event.message_type == MessageType.TEXT and not event.is_command():\n+            await self._enqueue_text_event(event)\n+            return\n+        if self._should_batch_media_event(event):\n+            await self._enqueue_media_event(event)\n+            return\n+        await self._handle_message_with_guards(event)\n+\n+    # =========================================================================\n+    # Media batching\n+    # =========================================================================\n+\n+    def _should_batch_media_event(self, event: MessageEvent) -> bool:\n+        return bool(\n+            event.media_urls\n+            and event.message_type in {MessageType.PHOTO, MessageType.VIDEO, MessageType.DOCUMENT, MessageType.AUDIO}\n+        )\n+\n+    def _media_batch_key(self, event: MessageEvent) -> str:\n+        from gateway.session import build_session_key\n+\n+        session_key = build_session_key(\n+            event.source,\n+            group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n+        )\n+        return f\"{session_key}:media:{event.message_type.value}\"\n+\n+    @staticmethod\n+    def _media_batch_is_compatible(existing: MessageEvent, incoming: MessageEvent) -> bool:\n+        return (\n+            existing.message_type == incoming.message_type\n+            and existing.reply_to_message_id == incoming.reply_to_message_id\n+            and existing.reply_to_text == incoming.reply_to_text\n+            and existing.source.thread_id == incoming.source.thread_id\n+        )\n+\n+    async def _enqueue_media_event(self, event: MessageEvent) -> None:\n+        key = self._media_batch_key(event)\n+        existing = self._pending_media_batches.get(key)\n+        if existing is None:\n+            self._pending_media_batches[key] = event\n+            self._schedule_media_batch_flush(key)\n+            return\n+        if not self._media_batch_is_compatible(existing, event):\n+            await self._flush_media_batch_now(key)\n+            self._pending_media_batches[key] = event\n+            self._schedule_media_batch_flush(key)\n+            return\n+        existing.media_urls.extend(event.media_urls)\n+        existing.media_types.extend(event.media_types)\n+        if event.text:\n+            if not existing.text:\n+                existing.text = event.text\n+            elif event.text not in existing.text.split(\"\\n\\n\"):\n+                existing.text = f\"{existing.text}\\n\\n{event.text}\"\n+        existing.timestamp = event.timestamp\n+        if event.message_id:\n+            existing.message_id = event.message_id\n+        self._schedule_media_batch_flush(key)\n+\n+    def _schedule_media_batch_flush(self, key: str) -> None:\n+        self._reschedule_batch_task(\n+            self._pending_media_batch_tasks,\n+            key,\n+            self._flush_media_batch,\n+        )\n+\n+    async def _flush_media_batch(self, key: str) -> None:\n+        current_task = asyncio.current_task()\n+        try:\n+            await asyncio.sleep(self._media_batch_delay_seconds)\n+            await self._flush_media_batch_now(key)\n+        finally:\n+            if self._pending_media_batch_tasks.get(key) is current_task:\n+                self._pending_media_batch_tasks.pop(key, None)\n+\n+    async def _flush_media_batch_now(self, key: str) -> None:\n+        event = self._pending_media_batches.pop(key, None)\n+        if not event:\n+            return\n+        logger.info(\n+            \"[Feishu] Flushing media batch %s with %d attachment(s)\",\n+            key,\n+            len(event.media_urls),\n+        )\n+        await self._handle_message_with_guards(event)\n+\n+    async def _download_remote_image(self, image_url: str) -> str:\n+        ext = self._guess_remote_extension(image_url, default=\".jpg\")\n+        return await cache_image_from_url(image_url, ext=ext)\n+\n+    async def _download_remote_document(\n+        self,\n+        file_url: str,\n+        *,\n+        default_ext: str,\n+        preferred_name: str,\n+    ) -> tuple[str, str]:\n+        import httpx\n+\n+        async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:\n+            response = await client.get(\n+                file_url,\n+                headers={\n+                    \"User-Agent\": \"Mozilla/5.0 (compatible; HermesAgent/1.0)\",\n+                    \"Accept\": \"*/*\",\n+                },\n+            )\n+            response.raise_for_status()\n+        filename = self._derive_remote_filename(\n+            file_url,\n+            content_type=str(response.headers.get(\"Content-Type\", \"\")),\n+            default_name=preferred_name,\n+            default_ext=default_ext,\n+        )\n+        cached_path = cache_document_from_bytes(response.content, filename)\n+        return cached_path, filename\n+\n+    @staticmethod\n+    def _guess_remote_extension(url: str, *, default: str) -> str:\n+        ext = Path((url or \"\").split(\"?\", 1)[0]).suffix.lower()\n+        return ext if ext in (_IMAGE_EXTENSIONS | _AUDIO_EXTENSIONS | _VIDEO_EXTENSIONS | set(SUPPORTED_DOCUMENT_TYPES)) else default\n+\n+    @staticmethod\n+    def _derive_remote_filename(file_url: str, *, content_type: str, default_name: str, default_ext: str) -> str:\n+        candidate = Path((file_url or \"\").split(\"?\", 1)[0]).name or default_name\n+        ext = Path(candidate).suffix.lower()\n+        if not ext:\n+            guessed = mimetypes.guess_extension((content_type or \"\").split(\";\", 1)[0].strip().lower() or \"\") or default_ext\n+            candidate = f\"{candidate}{guessed}\"\n+        return candidate\n+\n+    @staticmethod\n+    def _namespace_from_mapping(value: Any) -> Any:\n+        if isinstance(value, dict):\n+            return SimpleNamespace(**{key: FeishuAdapter._namespace_from_mapping(item) for key, item in value.items()})\n+        if isinstance(value, list):\n+            return [FeishuAdapter._namespace_from_mapping(item) for item in value]\n+        return value\n+\n+    async def _handle_webhook_request(self, request: Any) -> Any:\n+        remote_ip = (getattr(request, \"remote\", None) or \"unknown\")\n+\n+        # Rate limiting — composite key: app_id:path:remote_ip (matches openclaw key structure).\n+        rate_key = f\"{self._app_id}:{self._webhook_path}:{remote_ip}\"\n+        if not self._check_webhook_rate_limit(rate_key):\n+            logger.warning(\"[Feishu] Webhook rate limit exceeded for %s\", remote_ip)\n+            self._record_webhook_anomaly(remote_ip, \"429\")\n+            return web.Response(status=429, text=\"Too Many Requests\")\n+\n+        # Content-Type guard — Feishu always sends application/json.\n+        headers = getattr(request, \"headers\", {}) or {}\n+        content_type = str(headers.get(\"Content-Type\", \"\") or \"\").split(\";\")[0].strip().lower()\n+        if content_type and content_type != \"application/json\":\n+            logger.warning(\"[Feishu] Webhook rejected: unexpected Content-Type %r from %s\", content_type, remote_ip)\n+            self._record_webhook_anomaly(remote_ip, \"415\")\n+            return web.Response(status=415, text=\"Unsupported Media Type\")\n+\n+        # Body size guard — reject early via Content-Length when present.\n+        content_length = getattr(request, \"content_length\", None)\n+        if content_length is not None and content_length > _FEISHU_WEBHOOK_MAX_BODY_BYTES:\n+            logger.warning(\"[Feishu] Webhook body too large (%d bytes) from %s\", content_length, remote_ip)\n+            self._record_webhook_anomaly(remote_ip, \"413\")\n+            return web.Response(status=413, text=\"Request body too large\")\n+\n+        try:\n+            body_bytes: bytes = await asyncio.wait_for(\n+                request.read(),\n+                timeout=_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS,\n+            )\n+        except asyncio.TimeoutError:\n+            logger.warning(\"[Feishu] Webhook body read timed out after %ds from %s\", _FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS, remote_ip)\n+            self._record_webhook_anomaly(remote_ip, \"408\")\n+            return web.Response(status=408, text=\"Request Timeout\")\n+        except Exception:\n+            self._record_webhook_anomaly(remote_ip, \"400\")\n+            return web.json_response({\"code\": 400, \"msg\": \"failed to read body\"}, status=400)\n+\n+        if len(body_bytes) > _FEISHU_WEBHOOK_MAX_BODY_BYTES:\n+            logger.warning(\"[Feishu] Webhook body exceeds limit (%d bytes) from %s\", len(body_bytes), remote_ip)\n+            self._record_webhook_anomaly(remote_ip, \"413\")\n+            return web.Response(status=413, text=\"Request body too large\")\n+\n+        try:\n+            payload = json.loads(body_bytes.decode(\"utf-8\"))\n+        except (json.JSONDecodeError, UnicodeDecodeError):\n+            self._record_webhook_anomaly(remote_ip, \"400\")\n+            return web.json_response({\"code\": 400, \"msg\": \"invalid json\"}, status=400)\n+\n+        # URL verification challenge — respond before other checks so that Feishu's\n+        # subscription setup works even before encrypt_key is wired.\n+        if payload.get(\"type\") == \"url_verification\":\n+            return web.json_response({\"challenge\": payload.get(\"challenge\", \"\")})\n+\n+        # Verification token check — second layer of defence beyond signature (matches openclaw).\n+        if self._verification_token:\n+            header = payload.get(\"header\") or {}\n+            incoming_token = [redacted:secret-assignment]\"token\") or payload.get(\"token\") or \"\")\n+            if not incoming_token or not hmac.compare_digest(incoming_token, self._verification_token):\n+                logger.warning(\"[Feishu] Webhook rejected: invalid verification token from %s\", remote_ip)\n+                self._record_webhook_anomaly(remote_ip, \"401-token\")\n+                return web.Response(status=401, text=\"Invalid verification token\")\n+\n+        # Timing-safe signature verification (only enforced when encrypt_key is set).\n+        if self._encrypt_key and not self._is_webhook_signature_valid(request.headers, body_bytes):\n+            logger.warning(\"[Feishu] Webhook rejected: invalid signature from %s\", remote_ip)\n+            self._record_webhook_anomaly(remote_ip, \"401-sig\")\n+            return web.Response(status=401, text=\"Invalid signature\")\n+\n+        if payload.get(\"encrypt\"):\n+            logger.error(\"[Feishu] Encrypted webhook payloads are not supported by Hermes webhook mode\")\n+            self._record_webhook_anomaly(remote_ip, \"400-encrypted\")\n+            return web.json_response({\"code\": 400, \"msg\": \"encrypted webhook payloads are not supported\"}, status=400)\n+\n+        self._clear_webhook_anomaly(remote_ip)\n+\n+        event_type = str((payload.get(\"header\") or {}).get(\"event_type\") or \"\")\n+        data = self._namespace_from_mapping(payload)\n+        if event_type == \"im.message.receive_v1\":\n+            await self._handle_message_event_data(data)\n+        elif event_type == \"im.message.message_read_v1\":\n+            self._on_message_read_event(data)\n+        elif event_type == \"im.chat.member.bot.added_v1\":\n+            self._on_bot_added_to_chat(data)\n+        elif event_type == \"im.chat.member.bot.deleted_v1\":\n+            self._on_bot_removed_from_chat(data)\n+        elif event_type in (\"im.message.reaction.created_v1\", \"im.message.reaction.deleted_v1\"):\n+            self._on_reaction_event(event_type, data)\n+        elif event_type == \"card.action.trigger\":\n+            asyncio.ensure_future(self._handle_card_action_event(data))\n+        else:\n+            logger.debug(\"[Feishu] Ignoring webhook event type: %s\", event_type or \"unknown\")\n+        return web.json_response({\"code\": 0, \"msg\": \"ok\"})\n+\n+    def _is_webhook_signature_valid(self, headers: Any, body_bytes: bytes) -> bool:\n+        \"\"\"Verify Feishu webhook signature using timing-safe comparison.\n+\n+        Feishu signature algorithm:\n+            SHA256(timestamp + nonce + encrypt_key + body_string)\n+        Headers checked: x-lark-request-timestamp, x-lark-request-nonce, x-lark-signature.\n+        \"\"\"\n+        timestamp = str(headers.get(\"x-lark-request-timestamp\", \"\") or \"\")\n+        nonce = str(headers.get(\"x-lark-request-nonce\", \"\") or \"\")\n+        signature = str(headers.get(\"x-lark-signature\", \"\") or \"\")\n+        if not timestamp or not nonce or not signature:\n+            return False\n+        try:\n+            body_str = body_bytes.decode(\"utf-8\", errors=\"replace\")\n+            content = f\"{timestamp}{nonce}{self._encrypt_key}{body_str}\"\n+            computed = hashlib.sha256(content.encode(\"utf-8\")).hexdigest()\n+            return hmac.compare_digest(computed, signature)\n+        except Exception:\n+            logger.debug(\"[Feishu] Signature verification raised an exception\", exc_info=True)\n+            return False\n+\n+    def _check_webhook_rate_limit(self, rate_key: str) -> bool:\n+        \"\"\"Return False when the composite rate_key has exceeded _FEISHU_WEBHOOK_RATE_LIMIT_MAX.\n+\n+        The rate_key is composed as \"{app_id}:{path}:{remote_ip}\" — matching openclaw's key\n+        structure so the limit is scoped to a specific (account, endpoint, IP) triple rather\n+        than a bare IP, which causes fewer false-positive denials in multi-tenant setups.\n+\n+        The tracking dict is capped at _FEISHU_WEBHOOK_RATE_MAX_KEYS entries to prevent unbounded\n+        memory growth. Stale (expired) entries are pruned when the cap is reached.\n+        \"\"\"\n+        now = time.time()\n+        # Fast path: existing entry within the current window.\n+        entry = self._webhook_rate_counts.get(rate_key)\n+        if entry is not None:\n+            count, window_start = entry\n+            if now - window_start < _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS:\n+                if count >= _FEISHU_WEBHOOK_RATE_LIMIT_MAX:\n+                    return False\n+                self._webhook_rate_counts[rate_key] = (count + 1, window_start)\n+                return True\n+        # New window for an existing key, or a brand-new key — prune stale entries first.\n+        if len(self._webhook_rate_counts) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:\n+            stale_keys = [\n+                k for k, (_, ws) in self._webhook_rate_counts.items()\n+                if now - ws >= _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS\n+            ]\n+            for k in stale_keys:\n+                del self._webhook_rate_counts[k]\n+            # If still at capacity after pruning, allow through without tracking.\n+            if rate_key not in self._webhook_rate_counts and len(self._webhook_rate_counts) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:\n+                return True\n+        self._webhook_rate_counts[rate_key] = (1, now)\n+        return True\n+\n+    # =========================================================================\n+    # Text batching\n+    # =========================================================================\n+\n+    def _text_batch_key(self, event: MessageEvent) -> str:\n+        \"\"\"Return the session-scoped key used for Feishu text aggregation.\"\"\"\n+        from gateway.session import build_session_key\n+\n+        return build_session_key(\n+            event.source,\n+            group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n+        )\n+\n+    @staticmethod\n+    def _text_batch_is_compatible(existing: MessageEvent, incoming: MessageEvent) -> bool:\n+        \"\"\"Only merge text events when reply/thread context is identical.\"\"\"\n+        return (\n+            existing.reply_to_message_id == incoming.reply_to_message_id\n+            and existing.reply_to_text == incoming.reply_to_text\n+            and existing.source.thread_id == incoming.source.thread_id\n+        )\n+\n+    async def _enqueue_text_event(self, event: MessageEvent) -> None:\n+        \"\"\"Debounce rapid Feishu text bursts into a single MessageEvent.\"\"\"\n+        key = self._text_batch_key(event)\n+        existing = self._pending_text_batches.get(key)\n+        if existing is None:\n+            self._pending_text_batches[key] = event\n+            self._pending_text_batch_counts[key] = 1\n+            self._schedule_text_batch_flush(key)\n+            return\n+\n+        if not self._text_batch_is_compatible(existing, event):\n+            await self._flush_text_batch_now(key)\n+            self._pending_text_batches[key] = event\n+            self._pending_text_batch_counts[key] = 1\n+            self._schedule_text_batch_flush(key)\n+            return\n+\n+        existing_count = self._pending_text_batch_counts.get(key, 1)\n+        next_count = existing_count + 1\n+        appended_text = event.text or \"\"\n+        next_text = f\"{existing.text}\\n{appended_text}\" if existing.text and appended_text else (existing.text or appended_text)\n+        if next_count > self._text_batch_max_messages or len(next_text) > self._text_batch_max_chars:\n+            await self._flush_text_batch_now(key)\n+            self._pending_text_batches[key] = event\n+            self._pending_text_batch_counts[key] = 1\n+            self._schedule_text_batch_flush(key)\n+            return\n+\n+        existing.text = next_text\n+        existing.timestamp = event.timestamp\n+        if event.message_id:\n+            existing.message_id = event.message_id\n+        self._pending_text_batch_counts[key] = next_count\n+        self._schedule_text_batch_flush(key)\n+\n+    def _schedule_text_batch_flush(self, key: str) -> None:\n+        \"\"\"Reset the debounce timer for a pending Feishu text batch.\"\"\"\n+        self._reschedule_batch_task(\n+            self._pending_text_batch_tasks,\n+            key,\n+            self._flush_text_batch,\n+        )\n+\n+    @staticmethod\n+    def _reschedule_batch_task(\n+        task_map: Dict[str, asyncio.Task],\n+        key: str,\n+        flush_fn: Any,\n+    ) -> None:\n+        prior_task = task_map.get(key)\n+        if prior_task and not prior_task.done():\n+            prior_task.cancel()\n+        task_map[key] = asyncio.create_task(flush_fn(key))\n+\n+    async def _flush_text_batch(self, key: str) -> None:\n+        \"\"\"Flush a pending text batch after the quiet period.\"\"\"\n+        current_task = asyncio.current_task()\n+        try:\n+            await asyncio.sleep(self._text_batch_delay_seconds)\n+            await self._flush_text_batch_now(key)\n+        finally:\n+            if self._pending_text_batch_tasks.get(key) is current_task:\n+                self._pending_text_batch_tasks.pop(key, None)\n+\n+    async def _flush_text_batch_now(self, key: str) -> None:\n+        \"\"\"Dispatch the current text batch immediately.\"\"\"\n+        event = self._pending_text_batches.pop(key, None)\n+        self._pending_text_batch_counts.pop(key, None)\n+        if not event:\n+            return\n+        logger.info(\n+            \"[Feishu] Flushing text batch %s (%d chars)\",\n+            key,\n+            len(event.text or \"\"),\n+        )\n+        await self._handle_message_with_guards(event)\n+\n+    # =========================================================================\n+    # Message content extraction and resource download\n+    # =========================================================================\n+\n+    async def _extract_message_content(self, message: Any) -> tuple[str, MessageType, List[str], List[str]]:\n+        \"\"\"Extract text and cached media from a normalized Feishu message.\"\"\"\n+        raw_content = getattr(message, \"content\", \"\") or \"\"\n+        raw_type = getattr(message, \"message_type\", \"\") or \"\"\n+        message_id = str(getattr(message, \"message_id\", \"\") or \"\")\n+        logger.info(\"[Feishu] Received raw message type=%s message_id=%s\", raw_type, message_id)\n+\n+        normalized = normalize_feishu_message(message_type=raw_type, raw_content=raw_content)\n+        media_urls, media_types = await self._download_feishu_message_resources(\n+            message_id=message_id,\n+            normalized=normalized,\n+        )\n+        inbound_type = self._resolve_normalized_message_type(normalized, media_types)\n+        text = normalized.text_content\n+\n+        if (\n+            inbound_type in {MessageType.DOCUMENT, MessageType.AUDIO, MessageType.VIDEO, MessageType.PHOTO}\n+            and len(media_urls) == 1\n+            and normalized.preferred_message_type in {\"document\", \"audio\"}\n+        ):\n+            injected = await self._maybe_extract_text_document(media_urls[0], media_types[0])\n+            if injected:\n+                text = injected\n+\n+        return text, inbound_type, media_urls, media_types\n+\n+    async def _download_feishu_message_resources(\n+        self,\n+        *,\n+        message_id: str,\n+        normalized: FeishuNormalizedMessage,\n+    ) -> tuple[List[str], List[str]]:\n+        media_urls: List[str] = []\n+        media_types: List[str] = []\n+\n+        for image_key in normalized.image_keys:\n+            cached_path, media_type = await self._download_feishu_image(\n+                message_id=message_id,\n+                image_key=image_key,\n+            )\n+            if cached_path:\n+                media_urls.append(cached_path)\n+                media_types.append(media_type)\n+\n+        for media_ref in normalized.media_refs:\n+            cached_path, media_type = await self._download_feishu_message_resource(\n+                message_id=message_id,\n+                file_key=media_ref.file_key,\n+                resource_type=media_ref.resource_type,\n+                fallback_filename=media_ref.file_name,\n+            )\n+            if cached_path:\n+                media_urls.append(cached_path)\n+                media_types.append(media_type)\n+\n+        return media_urls, media_types\n+\n+    @staticmethod\n+    def _resolve_media_message_type(media_type: str, *, default: MessageType) -> MessageType:\n+        normalized = (media_type or \"\").lower()\n+        if normalized.startswith(\"image/\"):\n+            return MessageType.PHOTO\n+        if normalized.startswith(\"audio/\"):\n+            return MessageType.AUDIO\n+        if normalized.startswith(\"video/\"):\n+            return MessageType.VIDEO\n+        return default\n+\n+    def _resolve_normalized_message_type(\n+        self,\n+        normalized: FeishuNormalizedMessage,\n+        media_types: List[str],\n+    ) -> MessageType:\n+        preferred = normalized.preferred_message_type\n+        if preferred == \"photo\":\n+            return self._resolve_media_message_type(media_types[0] if media_types else \"\", default=MessageType.PHOTO)\n+        if preferred == \"audio\":\n+            return self._resolve_media_message_type(media_types[0] if media_types else \"\", default=MessageType.AUDIO)\n+        if preferred == \"document\":\n+            return self._resolve_media_message_type(media_types[0] if media_types else \"\", default=MessageType.DOCUMENT)\n+        return MessageType.TEXT\n+\n+    def _normalize_inbound_text(self, text: str) -> str:\n+        \"\"\"Strip Feishu mention placeholders from inbound text.\"\"\"\n+        text = _MENTION_RE.sub(\" \", text or \"\")\n+        text = _MULTISPACE_RE.sub(\" \", text)\n+        return text.strip()\n+\n+    async def _maybe_extract_text_document(self, cached_path: str, media_type: str) -> str:\n+        if not cached_path or not media_type.startswith(\"text/\"):\n+            return \"\"\n+        try:\n+            if os.path.getsize(cached_path) > _MAX_TEXT_INJECT_BYTES:\n+                return \"\"\n+            ext = Path(cached_path).suffix.lower()\n+            if ext not in {\".txt\", \".md\"} and media_type not in {\"text/plain\", \"text/markdown\"}:\n+                return \"\"\n+            content = Path(cached_path).read_text(encoding=\"utf-8\")\n+            display_name = self._display_name_from_cached_path(cached_path)\n+            return f\"[Content of {display_name}]:\\n{content}\"\n+        except (OSError, UnicodeDecodeError):\n+            logger.warning(\"[Feishu] Failed to inject text document content from %s\", cached_path, exc_info=True)\n+            return \"\"\n+\n+    async def _download_feishu_image(self, *, message_id: str, image_key: str) -> tuple[str, str]:\n+        if not self._client or not message_id:\n+            return \"\", \"\"\n+        try:\n+            request = self._build_message_resource_request(\n+                message_id=message_id,\n+                file_key=image_key,\n+                resource_type=\"image\",\n+            )\n+            response = await asyncio.to_thread(self._client.im.v1.message_resource.get, request)\n+            if not response or not response.success():\n+                logger.warning(\n+                    \"[Feishu] Failed to download image %s: %s %s\",\n+                    image_key,\n+                    getattr(response, \"code\", \"unknown\"),\n+                    getattr(response, \"msg\", \"request failed\"),\n+                )\n+                return \"\", \"\"\n+            raw_bytes = self._read_binary_response(response)\n+            if not raw_bytes:\n+                return \"\", \"\"\n+            content_type = self._get_response_header(response, \"Content-Type\")\n+            filename = getattr(response, \"file_name\", None) or f\"{image_key}.jpg\"\n+            ext = self._guess_extension(filename, content_type, \".jpg\", allowed=_IMAGE_EXTENSIONS)\n+            cached_path = cache_image_from_bytes(raw_bytes, ext=ext)\n+            media_type = self._normalize_media_type(content_type, default=self._default_image_media_type(ext))\n+            return cached_path, media_type\n+        except Exception:\n+            logger.warning(\"[Feishu] Failed to cache image resource %s\", image_key, exc_info=True)\n+            return \"\", \"\"\n+\n+    async def _download_feishu_message_resource(\n+        self,\n+        *,\n+        message_id: str,\n+        file_key: str,\n+        resource_type: str,\n+        fallback_filename: str,\n+    ) -> tuple[str, str]:\n+        if not self._client or not message_id:\n+            return \"\", \"\"\n+\n+        request_types = [resource_type]\n+        if resource_type in {\"audio\", \"media\"}:\n+            request_types.append(\"file\")\n+\n+        for request_type in request_types:\n+            try:\n+                request = self._build_message_resource_request(\n+                    message_id=message_id,\n+                    file_key=file_key,\n+                    resource_type=request_type,\n+                )\n+                response = await asyncio.to_thread(self._client.im.v1.message_resource.get, request)\n+                if not response or not response.success():\n+                    logger.debug(\n+                        \"[Feishu] Resource download failed for %s/%s via type=%s: %s %s\",\n+                        message_id,\n+                        file_key,\n+                        request_type,\n+                        getattr(response, \"code\", \"unknown\"),\n+                        getattr(response, \"msg\", \"request failed\"),\n+                    )\n+                    continue\n+\n+                raw_bytes = self._read_binary_response(response)\n+                if not raw_bytes:\n+                    continue\n+                content_type = self._get_response_header(response, \"Content-Type\")\n+                response_filename = getattr(response, \"file_name\", None) or \"\"\n+                filename = response_filename or fallback_filename or f\"{request_type}_{file_key}\"\n+                media_type = self._normalize_media_type(\n+                    content_type,\n+                    default=self._guess_media_type_from_filename(filename),\n+                )\n+\n+                if media_type.startswith(\"image/\"):\n+                    ext = self._guess_extension(filename, content_type, \".jpg\", allowed=_IMAGE_EXTENSIONS)\n+                    cached_path = cache_image_from_bytes(raw_bytes, ext=ext)\n+                    logger.info(\"[Feishu] Cached message image resource at %s\", cached_path)\n+                    return cached_path, media_type or self._default_image_media_type(ext)\n+\n+                if request_type == \"audio\" or media_type.startswith(\"audio/\"):\n+                    ext = self._guess_extension(filename, content_type, \".ogg\", allowed=_AUDIO_EXTENSIONS)\n+                    cached_path = cache_audio_from_bytes(raw_bytes, ext=ext)\n+                    logger.info(\"[Feishu] Cached message audio resource at %s\", cached_path)\n+                    return cached_path, (media_type or f\"audio/{ext.lstrip('.') or 'ogg'}\")\n+\n+                if media_type.startswith(\"video/\"):\n+                    if not Path(filename).suffix:\n+                        filename = f\"{filename}.mp4\"\n+                    cached_path = cache_document_from_bytes(raw_bytes, filename)\n+                    logger.info(\"[Feishu] Cached message video resource at %s\", cached_path)\n+                    return cached_path, media_type\n+\n+                if not Path(filename).suffix and media_type in _DOCUMENT_MIME_TO_EXT:\n+                    filename = f\"{filename}{_DOCUMENT_MIME_TO_EXT[media_type]}\"\n+                cached_path = cache_document_from_bytes(raw_bytes, filename)\n+                logger.info(\"[Feishu] Cached message document resource at %s\", cached_path)\n+                return cached_path, (media_type or self._guess_document_media_type(filename))\n+            except Exception:\n+                logger.warning(\n+                    \"[Feishu] Failed to cache message resource %s/%s\",\n+                    message_id,\n+                    file_key,\n+                    exc_info=True,\n+                )\n+        return \"\", \"\"\n+\n+    # =========================================================================\n+    # Static helpers — extension / media-type guessing\n+    # =========================================================================\n+\n+    @staticmethod\n+    def _read_binary_response(response: Any) -> bytes:\n+        file_obj = getattr(response, \"file\", None)\n+        if file_obj is None:\n+            return b\"\"\n+        if hasattr(file_obj, \"getvalue\"):\n+            return bytes(file_obj.getvalue())\n+        return bytes(file_obj.read())\n+\n+    @staticmethod\n+    def _get_response_header(response: Any, name: str) -> str:\n+        raw = getattr(response, \"raw\", None)\n+        headers = getattr(raw, \"headers\", {}) or {}\n+        return str(headers.get(name, headers.get(name.lower(), \"\")) or \"\").split(\";\", 1)[0].strip().lower()\n+\n+    @staticmethod\n+    def _guess_extension(filename: str, content_type: str, default: str, *, allowed: set[str]) -> str:\n+        ext = Path(filename or \"\").suffix.lower()\n+        if ext in allowed:\n+            return ext\n+        guessed = mimetypes.guess_extension((content_type or \"\").split(\";\", 1)[0].strip().lower() or \"\")\n+        if guessed in allowed:\n+            return guessed\n+        return default\n+\n+    @staticmethod\n+    def _normalize_media_type(content_type: str, *, default: str) -> str:\n+        normalized = (content_type or \"\").split(\";\", 1)[0].strip().lower()\n+        return normalized or default\n+\n+    @staticmethod\n+    def _guess_document_media_type(filename: str) -> str:\n+        ext = Path(filename or \"\").suffix.lower()\n+        return SUPPORTED_DOCUMENT_TYPES.get(ext, mimetypes.guess_type(filename or \"\")[0] or \"application/octet-stream\")\n+\n+    @staticmethod\n+    def _display_name_from_cached_path(path: str) -> str:\n+        basename = os.path.basename(path)\n+        parts = basename.split(\"_\", 2)\n+        display_name = parts[2] if len(parts) >= 3 else basename\n+        return re.sub(r\"[^\\w.\\- ]\", \"_\", display_name)\n+\n+    @staticmethod\n+    def _guess_media_type_from_filename(filename: str) -> str:\n+        guessed = (mimetypes.guess_type(filename or \"\")[0] or \"\").lower()\n+        if guessed:\n+            return guessed\n+        ext = Path(filename or \"\").suffix.lower()\n+        if ext in _VIDEO_EXTENSIONS:\n+            return f\"video/{ext.lstrip('.')}\"\n+        if ext in _AUDIO_EXTENSIONS:\n+            return f\"audio/{ext.lstrip('.')}\"\n+        if ext in _IMAGE_EXTENSIONS:\n+            return FeishuAdapter._default_image_media_type(ext)\n+        return \"\"\n+\n+    @staticmethod\n+    def _map_chat_type(raw_chat_type: str) -> str:\n+        normalized = (raw_chat_type or \"\").strip().lower()\n+        if normalized == \"p2p\":\n+            return \"dm\"\n+        if \"topic\" in normalized or \"thread\" in normalized or \"forum\" in normalized:\n+            return \"forum\"\n+        if normalized == \"group\":\n+            return \"group\"\n+        return \"dm\"\n+\n+    @staticmethod\n+    def _resolve_source_chat_type(*, chat_info: Dict[str, Any], event_chat_type: str) -> str:\n+        resolved = str(chat_info.get(\"type\") or \"\").strip().lower()\n+        if resolved in {\"group\", \"forum\"}:\n+            return resolved\n+        if event_chat_type == \"p2p\":\n+            return \"dm\"\n+        return \"group\"\n+\n+    async def _resolve_sender_profile(self, sender_id: Any) -> Dict[str, Optional[str]]:\n+        open_id = getattr(sender_id, \"open_id\", None) or None\n+        user_id = getattr(sender_id, \"user_id\", None) or None\n+        union_id = getattr(sender_id, \"union_id\", None) or None\n+        primary_id = open_id or user_id\n+        display_name = await self._resolve_sender_name_from_api(primary_id or union_id)\n+        return {\n+            \"user_id\": primary_id,\n+            \"user_name\": display_name,\n+            \"user_id_alt\": union_id,\n+        }\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+        \"\"\"\n+        if not sender_id or not self._client:\n+            return None\n+        trimmed = sender_id.strip()\n+        if not trimmed:\n+            return None\n+        now = time.time()\n+        cached = self._sender_name_cache.get(trimmed)\n+        if cached is not None:\n+            name, expire_at = cached\n+            if now < expire_at:\n+                return name\n+        try:\n+            from lark_oapi.api.contact.v3 import GetUserRequest  # lazy import\n+            if trimmed.startswith(\"ou_\"):\n+                id_type = \"open_id\"\n+            elif trimmed.startswith(\"on_\"):\n+                id_type = \"union_id\"\n+            else:\n+                id_type = \"user_id\"\n+            request = GetUserRequest.builder().user_id(trimmed).user_id_type(id_type).build()\n+            response = await asyncio.to_thread(self._client.contact.v3.user.get, request)\n+            if not response or not response.success():\n+                return None\n+            user = getattr(getattr(response, \"data\", None), \"user\", None)\n+            name = (\n+                getattr(user, \"name\", None)\n+                or getattr(user, \"display_name\", None)\n+                or getattr(user, \"nickname\", None)\n+                or getattr(user, \"en_name\", None)\n+            )\n+            if name and isinstance(name, str):\n+                name = name.strip()\n+                if name:\n+                    self._sender_name_cache[trimmed] = (name, now + _FEISHU_SENDER_NAME_TTL_SECONDS)\n+                    return name\n+        except Exception:\n+            logger.debug(\"[Feishu] Failed to resolve sender name for %s\", sender_id, 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\n+        if message_id in self._message_text_cache:\n+            return self._message_text_cache[message_id]\n+        try:\n+            request = self._build_get_message_request(message_id)\n+            response = await asyncio.to_thread(self._client.im.v1.message.get, request)\n+            if not response or getattr(response, \"success\", lambda: False)() is False:\n+                code = getattr(response, \"code\", \"unknown\")\n+                msg = getattr(response, \"msg\", \"message lookup failed\")\n+                logger.warning(\"[Feishu] Failed to fetch parent message %s: [%s] %s\", message_id, code, msg)\n+                return None\n+            items = getattr(getattr(response, \"data\", None), \"items\", None) or []\n+            parent = items[0] if items else None\n+            body = getattr(parent, \"body\", None)\n+            msg_type = getattr(parent, \"msg_type\", \"\") or \"\"\n+            raw_content = getattr(body, \"content\", \"\") or \"\"\n+            text = self._extract_text_from_raw_content(msg_type=msg_type, raw_content=raw_content)\n+            self._message_text_cache[message_id] = text\n+            return text\n+        except Exception:\n+            logger.warning(\"[Feishu] Failed to fetch parent message %s\", message_id, exc_info=True)\n+            return None\n+\n+    def _extract_text_from_raw_content(self, *, msg_type: str, raw_content: str) -> Optional[str]:\n+        normalized = normalize_feishu_message(message_type=msg_type, raw_content=raw_content)\n+        if normalized.text_content:\n+            return normalized.text_content\n+        placeholder = normalized.metadata.get(\"placeholder_text\") if isinstance(normalized.metadata, dict) else None\n+        return str(placeholder).strip() or None\n+\n+    @staticmethod\n+    def _default_image_media_type(ext: str) -> str:\n+        normalized_ext = (ext or \"\").lower()\n+        if normalized_ext in {\".jpg\", \".jpeg\"}:\n+            return \"image/jpeg\"\n+        return f\"image/{normalized_ext.lstrip('.') or 'jpeg'}\"\n+\n+    @staticmethod\n+    def _log_background_failure(future: Any) -> None:\n+        try:\n+            future.result()\n+        except Exception:\n+            logger.exception(\"[Feishu] Background inbound processing failed\")\n+\n+    # =========================================================================\n+    # Group policy and mention gating\n+    # =========================================================================\n+\n+    def _allow_group_message(self, sender_id: Any) -> bool:\n+        \"\"\"Current group policy gate for non-DM traffic.\"\"\"\n+        if self._group_policy == \"disabled\":\n+            return False\n+        sender_open_id = getattr(sender_id, \"open_id\", None) or getattr(sender_id, \"user_id\", None)\n+        if self._group_policy == \"open\":\n+            return True\n+        return bool(sender_open_id and sender_open_id in self._allowed_group_users)\n+\n+    def _should_accept_group_message(self, message: Any, sender_id: Any) -> bool:\n+        \"\"\"Require an explicit @mention before group messages enter the agent.\"\"\"\n+        if not self._allow_group_message(sender_id):\n+            return False\n+        # @_all is Feishu's @everyone placeholder — always route to the bot.\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+        normalized = normalize_feishu_message(\n+            message_type=getattr(message, \"message_type\", \"\") or \"\",\n+            raw_content=raw_content,\n+        )\n+        if normalized.mentioned_ids:\n+            return self._post_mentions_bot(normalized.mentioned_ids)\n+        return False\n+\n+    def _message_mentions_bot(self, mentions: List[Any]) -> bool:\n+        \"\"\"Check whether any mention targets the configured or inferred bot identity.\"\"\"\n+        for mention in mentions:\n+            mention_id = getattr(mention, \"id\", None)\n+            mention_open_id = getattr(mention_id, \"open_id\", None)\n+            mention_user_id = getattr(mention_id, \"user_id\", None)\n+            mention_name = (getattr(mention, \"name\", None) or \"\").strip()\n+\n+            if self._bot_open_id and mention_open_id == self._bot_open_id:\n+                return True\n+            if self._bot_user_id and mention_user_id == self._bot_user_id:\n+                return True\n+            if self._bot_name and mention_name == self._bot_name:\n+                return True\n+\n+        return False\n+\n+    def _post_mentions_bot(self, mentioned_ids: List[str]) -> bool:\n+        if not mentioned_ids:\n+            return False\n+        if self._bot_open_id and self._bot_open_id in mentioned_ids:\n+            return True\n+        if self._bot_user_id and self._bot_user_id in mentioned_ids:\n+            return True\n+        return False\n+\n+    async def _hydrate_bot_identity(self) -> None:\n+        \"\"\"Best-effort discovery of bot identity for precise group mention gating.\"\"\"\n+        if not self._client:\n+            return\n+        if any((self._bot_open_id, self._bot_user_id, self._bot_name)):\n+            return\n+        try:\n+            request = self._build_get_application_request(app_id=self._app_id, lang=\"en_us\")\n+            response = await asyncio.to_thread(self._client.application.v6.application.get, request)\n+            if not response or not response.success():\n+                code = getattr(response, \"code\", None)\n+                if code == 99991672:\n+                    logger.warning(\n+                        \"[Feishu] Unable to hydrate bot identity from application info. \"\n+                        \"Grant admin:app.info:readonly or application:application:self_manage \"\n+                        \"so group @mention gating can resolve the bot name precisely.\"\n+                    )\n+                return\n+            app = getattr(getattr(response, \"data\", None), \"app\", None)\n+            app_name = (getattr(app, \"app_name\", None) or \"\").strip()\n+            if app_name:\n+                self._bot_name = app_name\n+        except Exception:\n+            logger.debug(\"[Feishu] Failed to hydrate bot identity\", exc_info=True)\n+\n+    # =========================================================================\n+    # Deduplication — seen message ID cache (persistent)\n+    # =========================================================================\n+\n+    def _load_seen_message_ids(self) -> None:\n+        try:\n+            payload = json.loads(self._dedup_state_path.read_text(encoding=\"utf-8\"))\n+        except FileNotFoundError:\n+            return\n+        except (OSError, json.JSONDecodeError):\n+            logger.warning(\"[Feishu] Failed to load persisted dedup state from %s\", self._dedup_state_path, exc_info=True)\n+            return\n+        seen_data = payload.get(\"message_ids\", {}) if isinstance(payload, dict) else {}\n+        now = time.time()\n+        ttl = _FEISHU_DEDUP_TTL_SECONDS\n+        # Backward-compat: old format stored a plain list of IDs (no timestamps).\n+        if isinstance(seen_data, list):\n+            entries: Dict[str, float] = {str(item).strip(): 0.0 for item in seen_data if str(item).strip()}\n+        elif isinstance(seen_data, dict):\n+            entries = {k: float(v) for k, v in seen_data.items() if isinstance(k, str) and k.strip()}\n+        else:\n+            return\n+        # Filter out TTL-expired entries (entries saved with ts=0.0 are treated as immortal\n+        # for one migration cycle to avoid nuking old data on first upgrade).\n+        valid: Dict[str, float] = {\n+            msg_id: ts for msg_id, ts in entries.items()\n+            if ts == 0.0 or ttl <= 0 or now - ts < ttl\n+        }\n+        # Apply size cap; keep the most recently seen IDs.\n+        sorted_ids = sorted(valid, key=lambda k: valid[k], reverse=True)[:self._dedup_cache_size]\n+        self._seen_message_order = list(reversed(sorted_ids))\n+        self._seen_message_ids = {k: valid[k] for k in sorted_ids}\n+\n+    def _persist_seen_message_ids(self) -> None:\n+        try:\n+            self._dedup_state_path.parent.mkdir(parents=True, exist_ok=True)\n+            recent = self._seen_message_order[-self._dedup_cache_size:]\n+            # Save as {msg_id: timestamp} so TTL filtering works across restarts.\n+            payload = {\"message_ids\": {k: self._seen_message_ids[k] for k in recent if k in self._seen_message_ids}}\n+            self._dedup_state_path.write_text(json.dumps(payload, ensure_ascii=False), encoding=\"utf-8\")\n+        except OSError:\n+            logger.warning(\"[Feishu] Failed to persist dedup state to %s\", self._dedup_state_path, exc_info=True)\n+\n+    def _is_duplicate(self, message_id: str) -> bool:\n+        now = time.time()\n+        ttl = _FEISHU_DEDUP_TTL_SECONDS\n+        with self._dedup_lock:\n+            seen_at = self._seen_message_ids.get(message_id)\n+            if seen_at is not None and (ttl <= 0 or now - seen_at < ttl):\n+                return True\n+            # Record with current wall-clock timestamp so TTL works across restarts.\n+            self._seen_message_ids[message_id] = now\n+            self._seen_message_order.append(message_id)\n+            while len(self._seen_message_order) > self._dedup_cache_size:\n+                stale = self._seen_message_order.pop(0)\n+                self._seen_message_ids.pop(stale, None)\n+            self._persist_seen_message_ids()\n+            return False\n+\n+    # =========================================================================\n+    # Outbound payload construction and send pipeline\n+    # =========================================================================\n+\n+    def _build_outbound_payload(self, content: str) -> tuple[str, str]:\n+        if _MARKDOWN_HINT_RE.search(content):\n+            return \"post\", _build_markdown_post_payload(content)\n+        text_payload = {\"text\": content}\n+        return \"text\", json.dumps(text_payload, ensure_ascii=False)\n+\n+    async def _send_uploaded_file_message(\n+        self,\n+        *,\n+        chat_id: str,\n+        file_path: str,\n+        reply_to: Optional[str],\n+        metadata: Optional[Dict[str, Any]],\n+        caption: Optional[str] = None,\n+        file_name: Optional[str] = None,\n+        outbound_message_type: str = \"file\",\n+    ) -> SendResult:\n+        if not self._client:\n+            return SendResult(success=False, error=\"Not connected\")\n+        if not os.path.exists(file_path):\n+            return SendResult(success=False, error=f\"File not found: {file_path}\")\n+\n+        display_name = file_name or os.path.basename(file_path)\n+        upload_file_type, resolved_message_type = self._resolve_outbound_file_routing(\n+            file_path=display_name,\n+            requested_message_type=outbound_message_type,\n+        )\n+        try:\n+            with open(file_path, \"rb\") as file_obj:\n+                body = self._build_file_upload_body(\n+                    file_type=upload_file_type,\n+                    file_name=display_name,\n+                    file=file_obj,\n+                )\n+                request = self._build_file_upload_request(body)\n+                upload_response = await asyncio.to_thread(self._client.im.v1.file.create, request)\n+            file_key = self._extract_response_field(upload_response, \"file_key\")\n+            if not file_key:\n+                return self._response_error_result(\n+                    upload_response,\n+                    default_message=\"file upload failed\",\n+                    override_error=\"Feishu file upload missing file_key\",\n+                )\n+\n+            if caption:\n+                media_tag = {\n+                    \"tag\": \"media\",\n+                    \"file_key\": file_key,\n+                    \"file_name\": display_name,\n+                }\n+                message_response = await self._feishu_send_with_retry(\n+                    chat_id=chat_id,\n+                    msg_type=\"post\",\n+                    payload=self._build_media_post_payload(caption=caption, media_tag=media_tag),\n+                    reply_to=reply_to,\n+                    metadata=metadata,\n+                )\n+            else:\n+                message_response = await self._feishu_send_with_retry(\n+                    chat_id=chat_id,\n+                    msg_type=resolved_message_type,\n+                    payload=json.dumps({\"file_key\": file_key}, ensure_ascii=False),\n+                    reply_to=reply_to,\n+                    metadata=metadata,\n+                )\n+            return self._finalize_send_result(message_response, \"file send failed\")\n+        except Exception as exc:\n+            logger.error(\"[Feishu] Failed to send file %s: %s\", file_path, exc, exc_info=True)\n+            return SendResult(success=False, error=str(exc))\n+\n+    async def _send_raw_message(\n+        self,\n+        *,\n+        chat_id: str,\n+        msg_type: str,\n+        payload: str,\n+        reply_to: Optional[str],\n+        metadata: Optional[Dict[str, Any]],\n+    ) -> Any:\n+        reply_in_thread = bool((metadata or {}).get(\"thread_id\"))\n+        if reply_to:\n+            body = self._build_reply_message_body(\n+                content=payload,\n+                msg_type=msg_type,\n+                reply_in_thread=reply_in_thread,\n+                uuid_value=str(uuid.uuid4()),\n+            )\n+            request = self._build_reply_message_request(reply_to, body)\n+            return await asyncio.to_thread(self._client.im.v1.message.reply, request)\n+\n+        body = self._build_create_message_body(\n+            receive_id=chat_id,\n+            msg_type=msg_type,\n+            content=payload,\n+            uuid_value=str(uuid.uuid4()),\n+        )\n+        request = self._build_create_message_request(\"chat_id\", body)\n+        return await asyncio.to_thread(self._client.im.v1.message.create, request)\n+\n+    @staticmethod\n+    def _response_succeeded(response: Any) -> bool:\n+        return bool(response and getattr(response, \"success\", lambda: False)())\n+\n+    @staticmethod\n+    def _extract_response_field(response: Any, field_name: str) -> Any:\n+        if not FeishuAdapter._response_succeeded(response):\n+            return None\n+        data = getattr(response, \"data\", None)\n+        return getattr(data, field_name, None) if data else None\n+\n+    def _response_error_result(\n+        self,\n+        response: Any,\n+        *,\n+        default_message: str,\n+        override_error: Optional[str] = None,\n+    ) -> SendResult:\n+        if override_error:\n+            return SendResult(success=False, error=override_error, raw_response=response)\n+        code = getattr(response, \"code\", \"unknown\")\n+        msg = getattr(response, \"msg\", default_message)\n+        return SendResult(success=False, error=f\"[{code}] {msg}\", raw_response=response)\n+\n+    def _finalize_send_result(self, response: Any, default_message: str) -> SendResult:\n+        if not self._response_succeeded(response):\n+            return self._response_error_result(response, default_message=default_message)\n+        return SendResult(\n+            success=True,\n+            message_id=self._extract_response_field(response, \"message_id\"),\n+            raw_response=response,\n+        )\n+\n+    # =========================================================================\n+    # Connection internals — websocket / webhook setup\n+    # =========================================================================\n+\n+    async def _connect_with_retry(self) -> None:\n+        for attempt in range(_FEISHU_CONNECT_ATTEMPTS):\n+            try:\n+                if self._connection_mode == \"websocket\":\n+                    await self._connect_websocket()\n+                else:\n+                    await self._connect_webhook()\n+                return\n+            except Exception as exc:\n+                self._running = False\n+                self._disable_websocket_auto_reconnect()\n+                self._ws_future = None\n+                await self._stop_webhook_server()\n+                if attempt >= _FEISHU_CONNECT_ATTEMPTS - 1:\n+                    raise\n+                wait_seconds = 2 ** attempt\n+                logger.warning(\n+                    \"[Feishu] Connect attempt %d/%d failed; retrying in %ds: %s\",\n+                    attempt + 1,\n+                    _FEISHU_CONNECT_ATTEMPTS,\n+                    wait_seconds,\n+                    exc,\n+                )\n+                await asyncio.sleep(wait_seconds)\n+\n+    async def _connect_websocket(self) -> None:\n+        if not FEISHU_WEBSOCKET_AVAILABLE:\n+            raise RuntimeError(\"websockets not installed; websocket mode unavailable\")\n+        domain = FEISHU_DOMAIN if self._domain_name != \"lark\" else LARK_DOMAIN\n+        self._client = self._build_lark_client(domain)\n+        await self._hydrate_bot_identity()\n+        self._ws_client = FeishuWSClient(\n+            app_id=self._app_id,\n+            app_secret=[redacted:secret-assignment]\n+            log_level=lark.LogLevel.INFO,\n+            event_handler=self._event_handler,\n+            domain=domain,\n+        )\n+        self._ws_future = self._loop.run_in_executor(\n+            None,\n+            _run_official_feishu_ws_client,\n+            self._ws_client,\n+        )\n+\n+    async def _connect_webhook(self) -> None:\n+        if not FEISHU_WEBHOOK_AVAILABLE:\n+            raise RuntimeError(\"aiohttp not installed; webhook mode unavailable\")\n+        domain = FEISHU_DOMAIN if self._domain_name != \"lark\" else LARK_DOMAIN\n+        self._client = self._build_lark_client(domain)\n+        await self._hydrate_bot_identity()\n+        app = web.Application()\n+        app.router.add_post(self._webhook_path, self._handle_webhook_request)\n+        self._webhook_runner = web.AppRunner(app)\n+        await self._webhook_runner.setup()\n+        self._webhook_site = web.TCPSite(self._webhook_runner, self._webhook_host, self._webhook_port)\n+        await self._webhook_site.start()\n+\n+    def _build_lark_client(self, domain: Any) -> Any:\n+        return (\n+            lark.Client.builder()\n+            .app_id(self._app_id)\n+            .app_secret(self._app_secret)\n+            .domain(domain)\n+            .log_level(lark.LogLevel.WARNING)\n+            .build()\n+        )\n+\n+    async def _feishu_send_with_retry(\n+        self,\n+        *,\n+        chat_id: str,\n+        msg_type: str,\n+        payload: str,\n+        reply_to: Optional[str],\n+        metadata: Optional[Dict[str, Any]],\n+    ) -> Any:\n+        last_error: Optional[Exception] = None\n+        active_reply_to = reply_to\n+        for attempt in range(_FEISHU_SEND_ATTEMPTS):\n+            try:\n+                response = await self._send_raw_message(\n+                    chat_id=chat_id,\n+                    msg_type=msg_type,\n+                    payload=payload,\n+                    reply_to=active_reply_to,\n+                    metadata=metadata,\n+                )\n+                # If replying to a message failed because it was withdrawn or not found,\n+                # fall back to posting a new message directly to the chat.\n+                if active_reply_to and not self._response_succeeded(response):\n+                    code = getattr(response, \"code\", None)\n+                    if code in _FEISHU_REPLY_FALLBACK_CODES:\n+                        logger.warning(\n+                            \"[Feishu] Reply to %s failed (code %s — message withdrawn/missing); \"\n+                            \"falling back to new message in chat %s\",\n+                            active_reply_to,\n+                            code,\n+                            chat_id,\n+                        )\n+                        active_reply_to = None\n+                        response = await self._send_raw_message(\n+                            chat_id=chat_id,\n+                            msg_type=msg_type,\n+                            payload=payload,\n+                            reply_to=None,\n+                            metadata=metadata,\n+                        )\n+                return response\n+            except Exception as exc:\n+                last_error = exc\n+                if msg_type == \"post\" and _POST_CONTENT_INVALID_RE.search(str(exc)):\n+                    raise\n+                if attempt >= _FEISHU_SEND_ATTEMPTS - 1:\n+                    raise\n+                wait_seconds = 2 ** attempt\n+                logger.warning(\n+                    \"[Feishu] Send attempt %d/%d failed for chat %s; retrying in %ds: %s\",\n+                    attempt + 1,\n+                    _FEISHU_SEND_ATTEMPTS,\n+                    chat_id,\n+                    wait_seconds,\n+                    exc,\n+                )\n+                await asyncio.sleep(wait_seconds)\n+        raise last_error or RuntimeError(\"Feishu send failed\")\n+\n+    async def _release_app_lock(self) -> None:\n+        if not self._app_lock_identity:\n+            return\n+        try:\n+            release_scoped_lock(_FEISHU_APP_LOCK_SCOPE, self._app_lock_identity)\n+        except Exception as exc:\n+            logger.warning(\"[Feishu] Failed to release app lock: %s\", exc, exc_info=True)\n+        finally:\n+            self._app_lock_identity = None\n+\n+    # =========================================================================\n+    # Lark API request builders\n+    # =========================================================================\n+\n+    @staticmethod\n+    def _build_get_chat_request(chat_id: str) -> Any:\n+        if \"GetChatRequest\" in globals():\n+            return GetChatRequest.builder().chat_id(chat_id).build()\n+        return SimpleNamespace(chat_id=chat_id)\n+\n+    @staticmethod\n+    def _build_get_message_request(message_id: str) -> Any:\n+        if \"GetMessageRequest\" in globals():\n+            return GetMessageRequest.builder().message_id(message_id).build()\n+        return SimpleNamespace(message_id=message_id)\n+\n+    @staticmethod\n+    def _build_message_resource_request(*, message_id: str, file_key: str, resource_type: str) -> Any:\n+        if \"GetMessageResourceRequest\" in globals():\n+            return (\n+                GetMessageResourceRequest.builder()\n+                .message_id(message_id)\n+                .file_key(file_key)\n+                .type(resource_type)\n+                .build()\n+            )\n+        return SimpleNamespace(message_id=message_id, file_key=file_key, type=resource_type)\n+\n+    @staticmethod\n+    def _build_get_application_request(*, app_id: str, lang: str) -> Any:\n+        if \"GetApplicationRequest\" in globals():\n+            return (\n+                GetApplicationRequest.builder()\n+                .app_id(app_id)\n+                .lang(lang)\n+                .build()\n+            )\n+        return SimpleNamespace(app_id=app_id, lang=lang)\n+\n+    @staticmethod\n+    def _build_reply_message_body(*, content: str, msg_type: str, reply_in_thread: bool, uuid_value: str) -> Any:\n+        if \"ReplyMessageRequestBody\" in globals():\n+            return (\n+                ReplyMessageRequestBody.builder()\n+                .content(content)\n+                .msg_type(msg_type)\n+                .reply_in_thread(reply_in_thread)\n+                .uuid(uuid_value)\n+                .build()\n+            )\n+        return SimpleNamespace(\n+            content=content,\n+            msg_type=msg_type,\n+            reply_in_thread=reply_in_thread,\n+            uuid=uuid_value,\n+        )\n+\n+    @staticmethod\n+    def _build_reply_message_request(message_id: str, request_body: Any) -> Any:\n+        if \"ReplyMessageRequest\" in globals():\n+            return (\n+                ReplyMessageRequest.builder()\n+                .message_id(message_id)\n+                .request_body(request_body)\n+                .build()\n+            )\n+        return SimpleNamespace(message_id=message_id, request_body=request_body)\n+\n+    @staticmethod\n+    def _build_update_message_body(*, msg_type: str, content: str) -> Any:\n+        if \"UpdateMessageRequestBody\" in globals():\n+            return (\n+                UpdateMessageRequestBody.builder()\n+                .msg_type(msg_type)\n+                .content(content)\n+                .build()\n+            )\n+        return SimpleNamespace(msg_type=msg_type, content=content)\n+\n+    @staticmethod\n+    def _build_update_message_request(message_id: str, request_body: Any) -> Any:\n+        if \"UpdateMessageRequest\" in globals():\n+            return (\n+                UpdateMessageRequest.builder()\n+                .message_id(message_id)\n+                .request_body(request_body)\n+                .build()\n+            )\n+        return SimpleNamespace(message_id=message_id, request_body=request_body)\n+\n+    @staticmethod\n+    def _build_create_message_body(*, receive_id: str, msg_type: str, content: str, uuid_value: str) -> Any:\n+        if \"CreateMessageRequestBody\" in globals():\n+            return (\n+                CreateMessageRequestBody.builder()\n+                .receive_id(receive_id)\n+                .msg_type(msg_type)\n+                .content(content)\n+                .uuid(uuid_value)\n+                .build()\n+            )\n+        return SimpleNamespace(\n+            receive_id=receive_id,\n+            msg_type=msg_type,\n+            content=content,\n+            uuid=uuid_value,\n+        )\n+\n+    @staticmethod\n+    def _build_create_message_request(receive_id_type: str, request_body: Any) -> Any:\n+        if \"CreateMessageRequest\" in globals():\n+            return (\n+                CreateMessageRequest.builder()\n+                .receive_id_type(receive_id_type)\n+                .request_body(request_body)\n+                .build()\n+            )\n+        return SimpleNamespace(receive_id_type=receive_id_type, request_body=request_body)\n+\n+    @staticmethod\n+    def _build_image_upload_body(*, image_type: str, image: Any) -> Any:\n+        if \"CreateImageRequestBody\" in globals():\n+            return (\n+                CreateImageRequestBody.builder()\n+                .image_type(image_type)\n+                .image(image)\n+                .build()\n+            )\n+        return SimpleNamespace(image_type=image_type, image=image)\n+\n+    @staticmethod\n+    def _build_image_upload_request(request_body: Any) -> Any:\n+        if \"CreateImageRequest\" in globals():\n+            return CreateImageRequest.builder().request_body(request_body).build()\n+        return SimpleNamespace(request_body=request_body)\n+\n+    @staticmethod\n+    def _build_file_upload_body(*, file_type: str, file_name: str, file: Any) -> Any:\n+        if \"CreateFileRequestBody\" in globals():\n+            return (\n+                CreateFileRequestBody.builder()\n+                .file_type(file_type)\n+                .file_name(file_name)\n+                .file(file)\n+                .build()\n+            )\n+        return SimpleNamespace(file_type=file_type, file_name=file_name, file=file)\n+\n+    @staticmethod\n+    def _build_file_upload_request(request_body: Any) -> Any:\n+        if \"CreateFileRequest\" in globals():\n+            return CreateFileRequest.builder().request_body(request_body).build()\n+        return SimpleNamespace(request_body=request_body)\n+\n+    def _build_post_payload(self, content: str) -> str:\n+        return _build_markdown_post_payload(content)\n+\n+    def _build_media_post_payload(self, *, caption: str, media_tag: Dict[str, str]) -> str:\n+        payload = json.loads(self._build_post_payload(caption))\n+        content = payload.setdefault(\"zh_cn\", {}).setdefault(\"content\", [])\n+        content.append([media_tag])\n+        return json.dumps(payload, ensure_ascii=False)\n+\n+    @staticmethod\n+    def _resolve_outbound_file_routing(\n+        *,\n+        file_path: str,\n+        requested_message_type: str,\n+    ) -> tuple[str, str]:\n+        ext = Path(file_path).suffix.lower()\n+\n+        if ext in _FEISHU_OPUS_UPLOAD_EXTENSIONS:\n+            return \"opus\", \"audio\"\n+\n+        if ext in _FEISHU_MEDIA_UPLOAD_EXTENSIONS:\n+            return \"mp4\", \"media\"\n+\n+        if ext in _FEISHU_DOC_UPLOAD_TYPES:\n+            return _FEISHU_DOC_UPLOAD_TYPES[ext], \"file\"\n+\n+        if requested_message_type == \"file\":\n+            return _FEISHU_FILE_UPLOAD_TYPE, \"file\"\n+\n+        return _FEISHU_FILE_UPLOAD_TYPE, \"file\""]},{"file":"gateway/run.py","hunks":["@@ -982,6 +982,7 @@ async def start(self) -> bool:\n                        \"EMAIL_ALLOWED_USERS\",\n                        \"SMS_ALLOWED_USERS\", \"MATTERMOST_ALLOWED_USERS\",\n                        \"MATRIX_ALLOWED_USERS\", \"DINGTALK_ALLOWED_USERS\",\n+                       \"FEISHU_ALLOWED_USERS\",\n                        \"GATEWAY_ALLOWED_USERS\")\n         )\n         _allow_all = os.getenv(\"GATEWAY_ALLOW_ALL_USERS\", \"\").lower() in (\"true\", \"1\", \"yes\") or any(","@@ -990,7 +991,8 @@ async def start(self) -> bool:\n                        \"WHATSAPP_ALLOW_ALL_USERS\", \"SLACK_ALLOW_ALL_USERS\",\n                        \"SIGNAL_ALLOW_ALL_USERS\", \"EMAIL_ALLOW_ALL_USERS\",\n                        \"SMS_ALLOW_ALL_USERS\", \"MATTERMOST_ALLOW_ALL_USERS\",\n-                       \"MATRIX_ALLOW_ALL_USERS\", \"DINGTALK_ALLOW_ALL_USERS\")\n+                       \"MATRIX_ALLOW_ALL_USERS\", \"DINGTALK_ALLOW_ALL_USERS\",\n+                       \"FEISHU_ALLOW_ALL_USERS\")\n         )\n         if not _any_allowlist and not _allow_all:\n             logger.warning(","@@ -1433,6 +1435,13 @@ def _create_adapter(\n                 return None\n             return DingTalkAdapter(config)\n \n+        elif platform == Platform.FEISHU:\n+            from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements\n+            if not check_feishu_requirements():\n+                logger.warning(\"Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set\")\n+                return None\n+            return FeishuAdapter(config)\n+\n         elif platform == Platform.MATTERMOST:\n             from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements\n             if not check_mattermost_requirements():","@@ -1499,6 +1508,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n             Platform.MATTERMOST: \"MATTERMOST_ALLOWED_USERS\",\n             Platform.MATRIX: \"MATRIX_ALLOWED_USERS\",\n             Platform.DINGTALK: \"DINGTALK_ALLOWED_USERS\",\n+            Platform.FEISHU: \"FEISHU_ALLOWED_USERS\",\n         }\n         platform_allow_all_map = {\n             Platform.TELEGRAM: \"TELEGRAM_ALLOW_ALL_USERS\",","@@ -1511,6 +1521,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n             Platform.MATTERMOST: \"MATTERMOST_ALLOW_ALL_USERS\",\n             Platform.MATRIX: \"MATRIX_ALLOW_ALL_USERS\",\n             Platform.DINGTALK: \"DINGTALK_ALLOW_ALL_USERS\",\n+            Platform.FEISHU: \"FEISHU_ALLOW_ALL_USERS\",\n         }\n \n         # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)"]},{"file":"hermes_cli/config.py","hunks":["@@ -34,6 +34,7 @@\n     \"SIGNAL_ACCOUNT\", \"SIGNAL_HTTP_URL\",\n     \"SIGNAL_ALLOWED_USERS\", \"SIGNAL_GROUP_ALLOWED_USERS\",\n     \"DINGTALK_CLIENT_ID\", \"DINGTALK_CLIENT_SECRET\",\n+    \"FEISHU_APP_ID\", \"FEISHU_APP_SECRET\", \"FEISHU_ENCRYPT_KEY\", \"FEISHU_VERIFICATION_TOKEN\",\n     \"TERMINAL_ENV\", \"TERMINAL_SSH_KEY\", \"TERMINAL_SSH_PORT\",\n     \"WHATSAPP_MODE\", \"WHATSAPP_ENABLED\",\n     \"MATTERMOST_HOME_CHANNEL\", \"MATTERMOST_REPLY_MODE\","]},{"file":"hermes_cli/gateway.py","hunks":["@@ -1322,6 +1322,35 @@ def run_gateway(verbose: bool = False, replace: bool = False):\n              \"help\": \"The AppSecret from your DingTalk application credentials.\"},\n         ],\n     },\n+    {\n+        \"key\": \"feishu\",\n+        \"label\": \"Feishu / Lark\",\n+        \"emoji\": \"🪽\",\n+        \"token_var\": \"FEISHU_APP_ID\",\n+        \"setup_instructions\": [\n+            \"1. Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)\",\n+            \"2. Create an app and copy the App ID and App Secret\",\n+            \"3. Enable the Bot capability for the app\",\n+            \"4. Choose WebSocket (recommended) or Webhook connection mode\",\n+            \"5. Add the bot to a group chat or message it directly\",\n+            \"6. Restrict access with FEISHU_ALLOWED_USERS for production use\",\n+        ],\n+        \"vars\": [\n+            {\"name\": \"FEISHU_APP_ID\", \"prompt\": \"App ID\", \"password\": False,\n+             \"help\": \"The App ID from your Feishu/Lark application.\"},\n+            {\"name\": \"FEISHU_APP_SECRET\", \"prompt\": \"App Secret\", \"password\": True,\n+             \"help\": \"The App Secret from your Feishu/Lark application.\"},\n+            {\"name\": \"FEISHU_DOMAIN\", \"prompt\": \"Domain — feishu or lark (default: feishu)\", \"password\": False,\n+             \"help\": \"Use 'feishu' for Feishu China, or 'lark' for Lark international.\"},\n+            {\"name\": \"FEISHU_CONNECTION_MODE\", \"prompt\": \"Connection mode — websocket or webhook (default: websocket)\", \"password\": False,\n+             \"help\": \"websocket is recommended unless you specifically need webhook mode.\"},\n+            {\"name\": \"FEISHU_ALLOWED_USERS\", \"prompt\": \"Allowed user IDs (comma-separated, or empty)\", \"password\": False,\n+             \"is_allowlist\": True,\n+             \"help\": \"Restrict which Feishu/Lark users can interact with the bot.\"},\n+            {\"name\": \"FEISHU_HOME_CHANNEL\", \"prompt\": \"Home chat ID (optional, for cron/notifications)\", \"password\": False,\n+             \"help\": \"Chat ID for scheduled results and notifications.\"},\n+        ],\n+    },\n ]\n \n "]},{"file":"hermes_cli/skills_config.py","hunks":["@@ -28,6 +28,7 @@\n     \"mattermost\": \"💬 Mattermost\",\n     \"matrix\":   \"💬 Matrix\",\n     \"dingtalk\": \"💬 DingTalk\",\n+    \"feishu\": \"🪽 Feishu\",\n }\n \n # ─── Config Helpers ───────────────────────────────────────────────────────────"]},{"file":"hermes_cli/tools_config.py","hunks":["@@ -136,7 +136,8 @@ def _get_plugin_toolset_keys() -> set:\n     \"homeassistant\": {\"label\": \"🏠 Home Assistant\", \"default_toolset\": \"hermes-homeassistant\"},\n     \"email\":    {\"label\": \"📧 Email\",      \"default_toolset\": \"hermes-email\"},\n     \"matrix\":   {\"label\": \"💬 Matrix\",     \"default_toolset\": \"hermes-matrix\"},\n-    \"dingtalk\": {\"label\": \"💬 DingTalk\",   \"default_toolset\": \"hermes-dingtalk\"},\n+ \"dingtalk\": {\"label\": \"💬 DingTalk\", \"default_toolset\": \"hermes-dingtalk\"},\n+    \"feishu\": {\"label\": \"🪽 Feishu\", \"default_toolset\": \"hermes-feishu\"},\n     \"api_server\": {\"label\": \"🌐 API Server\", \"default_toolset\": \"hermes-api-server\"},\n     \"mattermost\": {\"label\": \"💬 Mattermost\", \"default_toolset\": \"hermes-mattermost\"},\n }"]},{"file":"pyproject.toml","hunks":["@@ -58,6 +58,7 @@ homeassistant = [\"aiohttp>=3.9.0,<4\"]\n sms = [\"aiohttp>=3.9.0,<4\"]\n acp = [\"agent-client-protocol>=0.8.1,<0.9\"]\n dingtalk = [\"dingtalk-stream>=0.1.0,<1\"]\n+feishu = [\"lark-oapi>=1.5.3,<2\"]\n rl = [\n   \"atroposlib @ git+https://github.com/NousResearch/atropos.git\",\n   \"tinker @ git+https://github.com/thinking-machines-lab/tinker.git\",","@@ -83,6 +84,7 @@ all = [\n   \"hermes-agent[acp]\",\n   \"hermes-agent[voice]\",\n   \"hermes-agent[dingtalk]\",\n+  \"hermes-agent[feishu]\",\n ]\n \n [project.scripts]"]},{"file":"tests/gateway/test_allowlist_startup_check.py","hunks":["@@ -13,7 +13,7 @@ def _would_warn():\n                    \"SIGNAL_ALLOWED_USERS\", \"SIGNAL_GROUP_ALLOWED_USERS\",\n                    \"EMAIL_ALLOWED_USERS\",\n                    \"SMS_ALLOWED_USERS\", \"MATTERMOST_ALLOWED_USERS\",\n-                   \"MATRIX_ALLOWED_USERS\", \"DINGTALK_ALLOWED_USERS\",\n+                   \"MATRIX_ALLOWED_USERS\", \"DINGTALK_ALLOWED_USERS\", \"FEISHU_ALLOWED_USERS\",\n                    \"GATEWAY_ALLOWED_USERS\")\n     )\n     _allow_all = os.getenv(\"GATEWAY_ALLOW_ALL_USERS\", \"\").lower() in (\"true\", \"1\", \"yes\") or any(","@@ -22,7 +22,7 @@ def _would_warn():\n                    \"WHATSAPP_ALLOW_ALL_USERS\", \"SLACK_ALLOW_ALL_USERS\",\n                    \"SIGNAL_ALLOW_ALL_USERS\", \"EMAIL_ALLOW_ALL_USERS\",\n                    \"SMS_ALLOW_ALL_USERS\", \"MATTERMOST_ALLOW_ALL_USERS\",\n-                   \"MATRIX_ALLOW_ALL_USERS\", \"DINGTALK_ALLOW_ALL_USERS\")\n+                   \"MATRIX_ALLOW_ALL_USERS\", \"DINGTALK_ALLOW_ALL_USERS\", \"FEISHU_ALLOW_ALL_USERS\")\n     )\n     return not _any_allowlist and not _allow_all\n "]},{"file":"tests/gateway/test_feishu.py","hunks":["@@ -0,0 +1,2580 @@\n+\"\"\"Tests for the Feishu gateway integration.\"\"\"\n+\n+import asyncio\n+import json\n+import os\n+import tempfile\n+import time\n+import unittest\n+from pathlib import Path\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, patch\n+\n+try:\n+    import lark_oapi\n+    _HAS_LARK_OAPI = True\n+except ImportError:\n+    _HAS_LARK_OAPI = False\n+\n+\n+class TestPlatformEnum(unittest.TestCase):\n+    def test_feishu_in_platform_enum(self):\n+        from gateway.config import Platform\n+\n+        self.assertEqual(Platform.FEISHU.value, \"feishu\")\n+\n+\n+class TestConfigEnvOverrides(unittest.TestCase):\n+    @patch.dict(os.environ, {\n+        \"FEISHU_APP_ID\": \"cli_xxx\",\n+        \"FEISHU_APP_SECRET\": \"secret_xxx\",\n+        \"FEISHU_CONNECTION_MODE\": \"websocket\",\n+        \"FEISHU_DOMAIN\": \"feishu\",\n+    }, clear=False)\n+    def test_feishu_config_loaded_from_env(self):\n+        from gateway.config import GatewayConfig, Platform, _apply_env_overrides\n+\n+        config = GatewayConfig()\n+        _apply_env_overrides(config)\n+\n+        self.assertIn(Platform.FEISHU, config.platforms)\n+        self.assertTrue(config.platforms[Platform.FEISHU].enabled)\n+        self.assertEqual(config.platforms[Platform.FEISHU].extra[\"app_id\"], \"cli_xxx\")\n+        self.assertEqual(config.platforms[Platform.FEISHU].extra[\"connection_mode\"], \"websocket\")\n+\n+    @patch.dict(os.environ, {\n+        \"FEISHU_APP_ID\": \"cli_xxx\",\n+        \"FEISHU_APP_SECRET\": \"secret_xxx\",\n+        \"FEISHU_HOME_CHANNEL\": \"oc_xxx\",\n+    }, clear=False)\n+    def test_feishu_home_channel_loaded(self):\n+        from gateway.config import GatewayConfig, Platform, _apply_env_overrides\n+\n+        config = GatewayConfig()\n+        _apply_env_overrides(config)\n+\n+        home = config.platforms[Platform.FEISHU].home_channel\n+        self.assertIsNotNone(home)\n+        self.assertEqual(home.chat_id, \"oc_xxx\")\n+\n+    @patch.dict(os.environ, {\n+        \"FEISHU_APP_ID\": \"cli_xxx\",\n+        \"FEISHU_APP_SECRET\": \"secret_xxx\",\n+    }, clear=False)\n+    def test_feishu_in_connected_platforms(self):\n+        from gateway.config import GatewayConfig, Platform, _apply_env_overrides\n+\n+        config = GatewayConfig()\n+        _apply_env_overrides(config)\n+\n+        self.assertIn(Platform.FEISHU, config.get_connected_platforms())\n+\n+\n+class TestGatewayIntegration(unittest.TestCase):\n+    def test_feishu_in_adapter_factory(self):\n+        source = Path(\"gateway/run.py\").read_text(encoding=\"utf-8\")\n+        self.assertIn(\"Platform.FEISHU\", source)\n+        self.assertIn(\"FeishuAdapter\", source)\n+\n+    def test_feishu_in_authorization_maps(self):\n+        source = Path(\"gateway/run.py\").read_text(encoding=\"utf-8\")\n+        self.assertIn(\"FEISHU_ALLOWED_USERS\", source)\n+        self.assertIn(\"FEISHU_ALLOW_ALL_USERS\", source)\n+\n+    def test_feishu_toolset_exists(self):\n+        from toolsets import TOOLSETS\n+\n+        self.assertIn(\"hermes-feishu\", TOOLSETS)\n+        self.assertIn(\"hermes-feishu\", TOOLSETS[\"hermes-gateway\"][\"includes\"])\n+\n+\n+class TestFeishuPostParsing(unittest.TestCase):\n+    def test_parse_post_content_extracts_text_mentions_and_media_refs(self):\n+        from gateway.platforms.feishu import parse_feishu_post_content\n+\n+        result = parse_feishu_post_content(\n+            json.dumps(\n+                {\n+                    \"en_us\": {\n+                        \"title\": \"Rich message\",\n+                        \"content\": [\n+                            [{\"tag\": \"img\", \"image_key\": \"img_1\", \"alt\": \"diagram\"}],\n+                            [{\"tag\": \"at\", \"user_name\": \"Alice\", \"open_id\": \"ou_alice\"}],\n+                            [{\"tag\": \"media\", \"file_key\": \"file_1\", \"file_name\": \"spec.pdf\"}],\n+                        ],\n+                    }\n+                }\n+            )\n+        )\n+\n+        self.assertEqual(result.text_content, \"Rich message\\n[Image: diagram]\\n@Alice\\n[Attachment: spec.pdf]\")\n+        self.assertEqual(result.image_keys, [\"img_1\"])\n+        self.assertEqual(result.mentioned_ids, [\"ou_alice\"])\n+        self.assertEqual(len(result.media_refs), 1)\n+        self.assertEqual(result.media_refs[0].file_key, \"file_1\")\n+        self.assertEqual(result.media_refs[0].file_name, \"spec.pdf\")\n+        self.assertEqual(result.media_refs[0].resource_type, \"file\")\n+\n+    def test_parse_post_content_uses_fallback_when_invalid(self):\n+        from gateway.platforms.feishu import FALLBACK_POST_TEXT, parse_feishu_post_content\n+\n+        result = parse_feishu_post_content(\"not-json\")\n+\n+        self.assertEqual(result.text_content, FALLBACK_POST_TEXT)\n+        self.assertEqual(result.image_keys, [])\n+        self.assertEqual(result.media_refs, [])\n+        self.assertEqual(result.mentioned_ids, [])\n+\n+    def test_parse_post_content_preserves_rich_text_semantics(self):\n+        from gateway.platforms.feishu import parse_feishu_post_content\n+\n+        result = parse_feishu_post_content(\n+            json.dumps(\n+                {\n+                    \"en_us\": {\n+                        \"title\": \"Plan *v2*\",\n+                        \"content\": [\n+                            [\n+                                {\"tag\": \"text\", \"text\": \"Bold\", \"style\": {\"bold\": True}},\n+                                {\"tag\": \"text\", \"text\": \" \"},\n+                                {\"tag\": \"text\", \"text\": \"Italic\", \"style\": {\"italic\": True}},\n+                                {\"tag\": \"text\", \"text\": \" \"},\n+                                {\"tag\": \"text\", \"text\": \"Code\", \"style\": {\"code\": True}},\n+                            ],\n+                            [{\"tag\": \"text\", \"text\": \"line1\"}, {\"tag\": \"br\"}, {\"tag\": \"text\", \"text\": \"line2\"}],\n+                            [{\"tag\": \"hr\"}],\n+                            [{\"tag\": \"code_block\", \"language\": \"python\", \"text\": \"print('hi')\"}],\n+                        ],\n+                    }\n+                }\n+            )\n+        )\n+\n+        self.assertEqual(\n+            result.text_content,\n+            \"Plan *v2*\\n**Bold** *Italic* `Code`\\nline1\\nline2\\n---\\n```python\\nprint('hi')\\n```\",\n+        )\n+\n+\n+class TestFeishuMessageNormalization(unittest.TestCase):\n+    def test_normalize_merge_forward_preserves_summary_lines(self):\n+        from gateway.platforms.feishu import normalize_feishu_message\n+\n+        normalized = normalize_feishu_message(\n+            message_type=\"merge_forward\",\n+            raw_content=json.dumps(\n+                {\n+                    \"title\": \"Sprint recap\",\n+                    \"messages\": [\n+                        {\"sender_name\": \"Alice\", \"text\": \"Please review PR-128\"},\n+                        {\n+                            \"sender_name\": \"Bob\",\n+                            \"message_type\": \"post\",\n+                            \"content\": {\n+                                \"en_us\": {\n+                                    \"content\": [[{\"tag\": \"text\", \"text\": \"Ship it\"}]],\n+                                }\n+                            },\n+                        },\n+                    ],\n+                }\n+            ),\n+        )\n+\n+        self.assertEqual(normalized.relation_kind, \"merge_forward\")\n+        self.assertEqual(\n+            normalized.text_content,\n+            \"Sprint recap\\n- Alice: Please review PR-128\\n- Bob: Ship it\",\n+        )\n+\n+    def test_normalize_share_chat_exposes_summary_and_metadata(self):\n+        from gateway.platforms.feishu import normalize_feishu_message\n+\n+        normalized = normalize_feishu_message(\n+            message_type=\"share_chat\",\n+            raw_content=json.dumps(\n+                {\n+                    \"chat_id\": \"oc_chat_shared\",\n+                    \"chat_name\": \"Backend Guild\",\n+                }\n+            ),\n+        )\n+\n+        self.assertEqual(normalized.relation_kind, \"share_chat\")\n+        self.assertEqual(normalized.text_content, \"Shared chat: Backend Guild\\nChat ID: oc_chat_shared\")\n+        self.assertEqual(normalized.metadata[\"chat_id\"], \"oc_chat_shared\")\n+        self.assertEqual(normalized.metadata[\"chat_name\"], \"Backend Guild\")\n+\n+    def test_normalize_interactive_card_preserves_title_body_and_actions(self):\n+        from gateway.platforms.feishu import normalize_feishu_message\n+\n+        normalized = normalize_feishu_message(\n+            message_type=\"interactive\",\n+            raw_content=json.dumps(\n+                {\n+                    \"card\": {\n+                        \"header\": {\"title\": {\"tag\": \"plain_text\", \"content\": \"Build Failed\"}},\n+                        \"elements\": [\n+                            {\"tag\": \"div\", \"text\": {\"tag\": \"lark_md\", \"content\": \"Service: payments-api\"}},\n+                            {\"tag\": \"div\", \"text\": {\"tag\": \"plain_text\", \"content\": \"Branch: main\"}},\n+                            {\n+                                \"tag\": \"action\",\n+                                \"actions\": [\n+                                    {\"tag\": \"button\", \"text\": {\"tag\": \"plain_text\", \"content\": \"View Logs\"}},\n+                                    {\"tag\": \"button\", \"text\": {\"tag\": \"plain_text\", \"content\": \"Retry\"}},\n+                                ],\n+                            },\n+                        ],\n+                    }\n+                }\n+            ),\n+        )\n+\n+        self.assertEqual(normalized.relation_kind, \"interactive\")\n+        self.assertEqual(\n+            normalized.text_content,\n+            \"Build Failed\\nService: payments-api\\nBranch: main\\nView Logs\\nRetry\\nActions: View Logs, Retry\",\n+        )\n+\n+\n+class TestFeishuAdapterMessaging(unittest.TestCase):\n+    @patch.dict(os.environ, {\n+        \"FEISHU_APP_ID\": \"cli_app\",\n+        \"FEISHU_APP_SECRET\": \"secret_app\",\n+        \"FEISHU_CONNECTION_MODE\": \"webhook\",\n+        \"FEISHU_WEBHOOK_HOST\": \"127.0.0.1\",\n+        \"FEISHU_WEBHOOK_PORT\": \"9001\",\n+        \"FEISHU_WEBHOOK_PATH\": \"/hook\",\n+    }, clear=True)\n+    def test_connect_webhook_mode_starts_local_server(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        runner = AsyncMock()\n+        site = AsyncMock()\n+        web_module = SimpleNamespace(\n+            Application=lambda: SimpleNamespace(router=SimpleNamespace(add_post=lambda *_args, **_kwargs: None)),\n+            AppRunner=lambda _app: runner,\n+            TCPSite=lambda _runner, host, port: SimpleNamespace(start=site.start, host=host, port=port),\n+        )\n+\n+        with (\n+            patch(\"gateway.platforms.feishu.FEISHU_AVAILABLE\", True),\n+            patch(\"gateway.platforms.feishu.FEISHU_WEBHOOK_AVAILABLE\", True),\n+            patch(\"gateway.platforms.feishu.acquire_scoped_lock\", return_value=(True, None)),\n+            patch(\"gateway.platforms.feishu.release_scoped_lock\"),\n+            patch.object(adapter, \"_hydrate_bot_identity\", new=AsyncMock()),\n+            patch.object(adapter, \"_build_lark_client\", return_value=SimpleNamespace()),\n+            patch(\"gateway.platforms.feishu.web\", web_module),\n+        ):\n+            connected = asyncio.run(adapter.connect())\n+\n+        self.assertTrue(connected)\n+        runner.setup.assert_awaited_once()\n+        site.start.assert_awaited_once()\n+\n+    @patch.dict(os.environ, {\n+        \"FEISHU_APP_ID\": \"cli_app\",\n+        \"FEISHU_APP_SECRET\": \"secret_app\",\n+    }, clear=True)\n+    def test_connect_acquires_scoped_lock_and_disconnect_releases_it(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        ws_client = object()\n+\n+        with (\n+            patch(\"gateway.platforms.feishu.FEISHU_AVAILABLE\", True),\n+            patch(\"gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE\", True),\n+            patch(\"gateway.platforms.feishu.lark\", SimpleNamespace(LogLevel=SimpleNamespace(INFO=\"INFO\", WARNING=\"WARNING\"))),\n+            patch(\"gateway.platforms.feishu.EventDispatcherHandler\", object()),\n+            patch(\"gateway.platforms.feishu.FeishuWSClient\", return_value=ws_client),\n+            patch(\"gateway.platforms.feishu._run_official_feishu_ws_client\"),\n+            patch(\"gateway.platforms.feishu.acquire_scoped_lock\", return_value=(True, None)) as acquire_lock,\n+            patch(\"gateway.platforms.feishu.release_scoped_lock\") as release_lock,\n+            patch.object(adapter, \"_hydrate_bot_identity\", new=AsyncMock()),\n+            patch.object(adapter, \"_build_lark_client\", return_value=SimpleNamespace()),\n+        ):\n+            loop = asyncio.new_event_loop()\n+            future = loop.create_future()\n+            future.set_result(None)\n+\n+            class _Loop:\n+                def run_in_executor(self, *_args, **_kwargs):\n+                    return future\n+\n+            try:\n+                with patch(\"gateway.platforms.feishu.asyncio.get_running_loop\", return_value=_Loop()):\n+                    connected = asyncio.run(adapter.connect())\n+                    asyncio.run(adapter.disconnect())\n+            finally:\n+                loop.close()\n+\n+        self.assertTrue(connected)\n+        acquire_lock.assert_called_once_with(\n+            \"feishu-app-id\",\n+            \"cli_app\",\n+            metadata={\"platform\": \"feishu\"},\n+        )\n+        release_lock.assert_called_once_with(\"feishu-app-id\", \"cli_app\")\n+\n+    @patch.dict(os.environ, {\n+        \"FEISHU_APP_ID\": \"cli_app\",\n+        \"FEISHU_APP_SECRET\": \"secret_app\",\n+    }, clear=True)\n+    def test_connect_rejects_existing_app_lock(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+\n+        with (\n+            patch(\"gateway.platforms.feishu.FEISHU_AVAILABLE\", True),\n+            patch(\"gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE\", True),\n+            patch(\n+                \"gateway.platforms.feishu.acquire_scoped_lock\",\n+                return_value=(False, {\"pid\": 4321}),\n+            ),\n+        ):\n+            connected = asyncio.run(adapter.connect())\n+\n+        self.assertFalse(connected)\n+        self.assertEqual(adapter.fatal_error_code, \"feishu_app_lock\")\n+        self.assertFalse(adapter.fatal_error_retryable)\n+        self.assertIn(\"PID 4321\", adapter.fatal_error_message)\n+\n+    @patch.dict(os.environ, {\n+        \"FEISHU_APP_ID\": \"cli_app\",\n+        \"FEISHU_APP_SECRET\": \"secret_app\",\n+    }, clear=True)\n+    def test_connect_retries_transient_startup_failure(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        ws_client = object()\n+        sleeps = []\n+\n+        with (\n+            patch(\"gateway.platforms.feishu.FEISHU_AVAILABLE\", True),\n+            patch(\"gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE\", True),\n+            patch(\"gateway.platforms.feishu.lark\", SimpleNamespace(LogLevel=SimpleNamespace(INFO=\"INFO\", WARNING=\"WARNING\"))),\n+            patch(\"gateway.platforms.feishu.EventDispatcherHandler\", object()),\n+            patch(\"gateway.platforms.feishu.FeishuWSClient\", return_value=ws_client),\n+            patch(\"gateway.platforms.feishu.acquire_scoped_lock\", return_value=(True, None)),\n+            patch(\"gateway.platforms.feishu.release_scoped_lock\"),\n+            patch.object(adapter, \"_hydrate_bot_identity\", new=AsyncMock()),\n+            patch(\"gateway.platforms.feishu.asyncio.sleep\", side_effect=lambda delay: sleeps.append(delay)),\n+            patch.object(adapter, \"_build_lark_client\", return_value=SimpleNamespace()),\n+        ):\n+            loop = asyncio.new_event_loop()\n+            future = loop.create_future()\n+            future.set_result(None)\n+\n+            class _Loop:\n+                def __init__(self):\n+                    self.calls = 0\n+\n+                def run_in_executor(self, *_args, **_kwargs):\n+                    self.calls += 1\n+                    if self.calls == 1:\n+                        raise OSError(\"temporary websocket failure\")\n+                    return future\n+\n+            fake_loop = _Loop()\n+            try:\n+                with patch(\"gateway.platforms.feishu.asyncio.get_running_loop\", return_value=fake_loop):\n+                    connected = asyncio.run(adapter.connect())\n+            finally:\n+                loop.close()\n+\n+        self.assertTrue(connected)\n+        self.assertEqual(sleeps, [1])\n+        self.assertEqual(fake_loop.calls, 2)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_edit_message_updates_existing_feishu_message(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _MessageAPI:\n+            def update(self, request):\n+                captured[\"request\"] = request\n+                return SimpleNamespace(success=lambda: True)\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    message=_MessageAPI(),\n+                )\n+            )\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(\n+                adapter.edit_message(\n+                    chat_id=\"oc_chat\",\n+                    message_id=\"om_progress\",\n+                    content=\"📖 read_file: \\\"/tmp/image.png\\\"\",\n+                )\n+            )\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(result.message_id, \"om_progress\")\n+        self.assertEqual(captured[\"request\"].message_id, \"om_progress\")\n+        self.assertEqual(captured[\"request\"].request_body.msg_type, \"text\")\n+        self.assertEqual(\n+            captured[\"request\"].request_body.content,\n+            json.dumps({\"text\": \"📖 read_file: \\\"/tmp/image.png\\\"\"}, ensure_ascii=False),\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_edit_message_falls_back_to_text_when_post_update_is_rejected(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {\"calls\": []}\n+\n+        class _MessageAPI:\n+            def update(self, request):\n+                captured[\"calls\"].append(request)\n+                if len(captured[\"calls\"]) == 1:\n+                    return SimpleNamespace(success=lambda: False, code=230001, msg=\"content format of the post type is incorrect\")\n+                return SimpleNamespace(success=lambda: True)\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    message=_MessageAPI(),\n+                )\n+            )\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(\n+                adapter.edit_message(\n+                    chat_id=\"oc_chat\",\n+                    message_id=\"om_progress\",\n+                    content=\"可以用 **粗体** 和 *斜体*。\",\n+                )\n+            )\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(captured[\"calls\"][0].request_body.msg_type, \"post\")\n+        self.assertEqual(captured[\"calls\"][1].request_body.msg_type, \"text\")\n+        self.assertEqual(\n+            captured[\"calls\"][1].request_body.content,\n+            json.dumps({\"text\": \"可以用 粗体 和 斜体。\"}, ensure_ascii=False),\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_get_chat_info_uses_real_feishu_chat_api(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+\n+        class _ChatAPI:\n+            def get(self, request):\n+                self.request = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(name=\"Hermes Group\", chat_type=\"group\"),\n+                )\n+\n+        chat_api = _ChatAPI()\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    chat=chat_api,\n+                )\n+            )\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+            info = asyncio.run(adapter.get_chat_info(\"oc_chat\"))\n+\n+        self.assertEqual(chat_api.request.chat_id, \"oc_chat\")\n+        self.assertEqual(info[\"chat_id\"], \"oc_chat\")\n+        self.assertEqual(info[\"name\"], \"Hermes Group\")\n+        self.assertEqual(info[\"type\"], \"group\")\n+\n+class TestAdapterModule(unittest.TestCase):\n+    def test_adapter_requirement_helper_exists(self):\n+        source = Path(\"gateway/platforms/feishu.py\").read_text(encoding=\"utf-8\")\n+        self.assertIn(\"def check_feishu_requirements()\", source)\n+        self.assertIn(\"FEISHU_AVAILABLE\", source)\n+\n+    def test_adapter_declares_websocket_scope(self):\n+        source = Path(\"gateway/platforms/feishu.py\").read_text(encoding=\"utf-8\")\n+        self.assertIn(\"Supported modes: websocket, webhook\", source)\n+        self.assertIn(\"FEISHU_CONNECTION_MODE\", source)\n+\n+    def test_adapter_registers_message_read_noop_handler(self):\n+        source = Path(\"gateway/platforms/feishu.py\").read_text(encoding=\"utf-8\")\n+        self.assertIn(\"register_p2_im_message_message_read_v1\", source)\n+        self.assertIn(\"def _on_message_read_event\", source)\n+\n+    def test_adapter_registers_reaction_and_card_handlers_for_websocket(self):\n+        source = Path(\"gateway/platforms/feishu.py\").read_text(encoding=\"utf-8\")\n+        self.assertIn(\"register_p2_im_message_reaction_created_v1\", source)\n+        self.assertIn(\"register_p2_im_message_reaction_deleted_v1\", source)\n+        self.assertIn(\"register_p2_card_action_trigger\", source)\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):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        calls = []\n+\n+        class _Builder:\n+            def register_p2_im_message_message_read_v1(self, _handler):\n+                calls.append(\"message_read\")\n+                return self\n+\n+            def register_p2_im_message_receive_v1(self, _handler):\n+                calls.append(\"message_receive\")\n+                return self\n+\n+            def register_p2_im_message_reaction_created_v1(self, _handler):\n+                calls.append(\"reaction_created\")\n+                return self\n+\n+            def register_p2_im_message_reaction_deleted_v1(self, _handler):\n+                calls.append(\"reaction_deleted\")\n+                return self\n+\n+            def register_p2_card_action_trigger(self, _handler):\n+                calls.append(\"card_action\")\n+                return self\n+\n+            def build(self):\n+                calls.append(\"build\")\n+                return \"handler\"\n+\n+        class _Dispatcher:\n+            @staticmethod\n+            def builder(_encrypt_key, _verification_token):\n+                calls.append(\"builder\")\n+                return _Builder()\n+\n+        with patch(\"gateway.platforms.feishu.EventDispatcherHandler\", _Dispatcher):\n+            handler = adapter._build_event_handler()\n+\n+        self.assertEqual(handler, \"handler\")\n+        self.assertEqual(\n+            calls,\n+            [\n+                \"builder\",\n+                \"message_read\",\n+                \"message_receive\",\n+                \"reaction_created\",\n+                \"reaction_deleted\",\n+                \"card_action\",\n+                \"build\",\n+            ],\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    @unittest.skipUnless(_HAS_LARK_OAPI, \"lark-oapi not installed\")\n+    def test_add_ack_reaction_uses_ok_emoji(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _ReactionAPI:\n+            def create(self, request):\n+                captured[\"request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(reaction_id=\"r_typing\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(v1=SimpleNamespace(message_reaction=_ReactionAPI()))\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+            reaction_id = asyncio.run(adapter._add_ack_reaction(\"om_msg\"))\n+\n+        self.assertEqual(reaction_id, \"r_typing\")\n+        self.assertEqual(captured[\"request\"].request_body.reaction_type[\"emoji_type\"], \"OK\")\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_add_ack_reaction_logs_warning_on_failure(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+\n+        class _ReactionAPI:\n+            def create(self, request):\n+                raise RuntimeError(\"boom\")\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(v1=SimpleNamespace(message_reaction=_ReactionAPI()))\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with (\n+            patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct),\n+            self.assertLogs(\"gateway.platforms.feishu\", level=\"WARNING\") as logs,\n+        ):\n+            reaction_id = asyncio.run(adapter._add_ack_reaction(\"om_msg\"))\n+\n+        self.assertIsNone(reaction_id)\n+        self.assertTrue(\n+            any(\"Failed to add ack reaction to om_msg\" in entry for entry in logs.output),\n+            logs.output,\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_ack_reaction_events_are_ignored_to_avoid_feedback_loops(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._loop = object()\n+        event = SimpleNamespace(\n+            message_id=\"om_msg\",\n+            operator_type=\"user\",\n+            reaction_type=SimpleNamespace(emoji_type=\"OK\"),\n+        )\n+        data = SimpleNamespace(event=event)\n+\n+        with patch(\"gateway.platforms.feishu.asyncio.run_coroutine_threadsafe\") as run_threadsafe:\n+            adapter._on_reaction_event(\"im.message.reaction.created_v1\", data)\n+\n+        run_threadsafe.assert_not_called()\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_normalize_inbound_text_strips_feishu_mentions(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        cleaned = adapter._normalize_inbound_text(\"hi @_user_1  there @_user_2\")\n+        self.assertEqual(cleaned, \"hi there\")\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\n+        from gateway.platforms.feishu import FeishuAdapter\n+\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+\n+        message_with_mention = SimpleNamespace(mentions=[SimpleNamespace(key=\"@_user_1\")])\n+        self.assertFalse(adapter._should_accept_group_message(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):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        sender_id = SimpleNamespace(open_id=\"ou_any\", user_id=None)\n+        other_mention = SimpleNamespace(\n+            name=\"Other User\",\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_GROUP_POLICY\": \"allowlist\",\n+            \"FEISHU_ALLOWED_USERS\": \"ou_allowed\",\n+            \"FEISHU_BOT_NAME\": \"Hermes Bot\",\n+        },\n+        clear=True,\n+    )\n+    def test_group_message_allowlist_and_mention_both_required(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        mentioned = SimpleNamespace(\n+            mentions=[\n+                SimpleNamespace(\n+                    name=\"Hermes Bot\",\n+                    id=SimpleNamespace(open_id=\"ou_other\", user_id=\"u_other\"),\n+                )\n+            ]\n+        )\n+\n+        self.assertTrue(\n+            adapter._should_accept_group_message(\n+                mentioned,\n+                SimpleNamespace(open_id=\"ou_allowed\", user_id=None),\n+            )\n+        )\n+        self.assertFalse(\n+            adapter._should_accept_group_message(\n+                mentioned,\n+                SimpleNamespace(open_id=\"ou_blocked\", user_id=None),\n+            )\n+        )\n+\n+    @patch.dict(\n+        os.environ,\n+        {\n+            \"FEISHU_GROUP_POLICY\": \"open\",\n+            \"FEISHU_BOT_OPEN_ID\": \"ou_bot\",\n+        },\n+        clear=True,\n+    )\n+    def test_group_message_matches_bot_open_id_when_configured(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        sender_id = SimpleNamespace(open_id=\"ou_any\", user_id=None)\n+\n+        bot_mention = SimpleNamespace(\n+            name=\"Hermes\",\n+            id=SimpleNamespace(open_id=\"ou_bot\", user_id=\"u_bot\"),\n+        )\n+        other_mention = SimpleNamespace(\n+            name=\"Other\",\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+\n+    @patch.dict(\n+        os.environ,\n+        {\n+            \"FEISHU_GROUP_POLICY\": \"open\",\n+            \"FEISHU_BOT_NAME\": \"Hermes Bot\",\n+        },\n+        clear=True,\n+    )\n+    def test_group_message_matches_bot_name_when_only_name_available(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        sender_id = SimpleNamespace(open_id=\"ou_any\", user_id=None)\n+\n+        named_mention = SimpleNamespace(\n+            name=\"Hermes Bot\",\n+            id=SimpleNamespace(open_id=\"ou_other\", user_id=\"u_other\"),\n+        )\n+        different_mention = SimpleNamespace(\n+            name=\"Another Bot\",\n+            id=SimpleNamespace(open_id=\"ou_other\", user_id=\"u_other\"),\n+        )\n+\n+        self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[named_mention]), sender_id))\n+        self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[different_mention]), sender_id))\n+\n+    @patch.dict(\n+        os.environ,\n+        {\n+            \"FEISHU_GROUP_POLICY\": \"open\",\n+            \"FEISHU_BOT_OPEN_ID\": \"ou_bot\",\n+        },\n+        clear=True,\n+    )\n+    def test_group_post_message_uses_parsed_mentions_when_sdk_mentions_missing(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        sender_id = SimpleNamespace(open_id=\"ou_any\", user_id=None)\n+        message = SimpleNamespace(\n+            message_type=\"post\",\n+            mentions=[],\n+            content='{\"en_us\":{\"content\":[[{\"tag\":\"at\",\"user_name\":\"Hermes\",\"open_id\":\"ou_bot\"}]]}}',\n+        )\n+\n+        self.assertTrue(adapter._should_accept_group_message(message, sender_id))\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_post_message_as_text(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        message = SimpleNamespace(\n+            message_type=\"post\",\n+            content='{\"zh_cn\":{\"title\":\"Title\",\"content\":[[{\"tag\":\"text\",\"text\":\"hello \"}],[{\"tag\":\"a\",\"text\":\"doc\",\"href\":\"https://example.com\"}]]}}',\n+            message_id=\"om_post\",\n+        )\n+\n+        text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))\n+\n+        self.assertEqual(text, \"Title\\nhello\\n[doc](https://example.com)\")\n+        self.assertEqual(msg_type.value, \"text\")\n+        self.assertEqual(media_urls, [])\n+        self.assertEqual(media_types, [])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_post_message_uses_first_available_language_block(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        message = SimpleNamespace(\n+            message_type=\"post\",\n+            content='{\"fr_fr\":{\"title\":\"Subject\",\"content\":[[{\"tag\":\"text\",\"text\":\"bonjour\"}]]}}',\n+            message_id=\"om_post_fr\",\n+        )\n+\n+        text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))\n+\n+        self.assertEqual(text, \"Subject\\nbonjour\")\n+        self.assertEqual(msg_type.value, \"text\")\n+        self.assertEqual(media_urls, [])\n+        self.assertEqual(media_types, [])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_post_message_with_rich_elements_does_not_drop_content(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        message = SimpleNamespace(\n+            message_type=\"post\",\n+            content=(\n+                '{\"en_us\":{\"title\":\"Rich message\",\"content\":['\n+                '[{\"tag\":\"img\",\"alt\":\"diagram\"}],'\n+                '[{\"tag\":\"at\",\"user_name\":\"Alice\"},{\"tag\":\"text\",\"text\":\" please check the attachment\"}],'\n+                '[{\"tag\":\"media\",\"file_name\":\"spec.pdf\"}],'\n+                '[{\"tag\":\"emotion\",\"emoji_type\":\"smile\"}]'\n+                ']}}'\n+            ),\n+            message_id=\"om_post_rich\",\n+        )\n+\n+        text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))\n+\n+        self.assertEqual(text, \"Rich message\\n[Image: diagram]\\n@Alice please check the attachment\\n[Attachment: spec.pdf]\\n:smile:\")\n+        self.assertEqual(msg_type.value, \"text\")\n+        self.assertEqual(media_urls, [])\n+        self.assertEqual(media_types, [])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_post_message_downloads_embedded_resources(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._download_feishu_image = AsyncMock(return_value=(\"/tmp/feishu-image.png\", \"image/png\"))\n+        adapter._download_feishu_message_resource = AsyncMock(return_value=(\"/tmp/spec.pdf\", \"application/pdf\"))\n+        message = SimpleNamespace(\n+            message_type=\"post\",\n+            content=(\n+                '{\"en_us\":{\"title\":\"Rich message\",\"content\":['\n+                '[{\"tag\":\"img\",\"image_key\":\"img_123\",\"alt\":\"diagram\"}],'\n+                '[{\"tag\":\"media\",\"file_key\":\"file_123\",\"file_name\":\"spec.pdf\"}]'\n+                ']}}'\n+            ),\n+            message_id=\"om_post_media\",\n+        )\n+\n+        text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))\n+\n+        self.assertEqual(text, \"Rich message\\n[Image: diagram]\\n[Attachment: spec.pdf]\")\n+        self.assertEqual(msg_type.value, \"text\")\n+        self.assertEqual(media_urls, [\"/tmp/feishu-image.png\", \"/tmp/spec.pdf\"])\n+        self.assertEqual(media_types, [\"image/png\", \"application/pdf\"])\n+        adapter._download_feishu_image.assert_awaited_once_with(\n+            message_id=\"om_post_media\",\n+            image_key=\"img_123\",\n+        )\n+        adapter._download_feishu_message_resource.assert_awaited_once_with(\n+            message_id=\"om_post_media\",\n+            file_key=\"file_123\",\n+            resource_type=\"file\",\n+            fallback_filename=\"spec.pdf\",\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_merge_forward_message_as_text_summary(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        message = SimpleNamespace(\n+            message_type=\"merge_forward\",\n+            content=json.dumps(\n+                {\n+                    \"title\": \"Forwarded updates\",\n+                    \"messages\": [\n+                        {\"sender_name\": \"Alice\", \"text\": \"Investigating the incident\"},\n+                        {\"sender_name\": \"Bob\", \"text\": \"ETA 10 minutes\"},\n+                    ],\n+                }\n+            ),\n+            message_id=\"om_merge_forward\",\n+        )\n+\n+        text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))\n+\n+        self.assertEqual(\n+            text,\n+            \"Forwarded updates\\n- Alice: Investigating the incident\\n- Bob: ETA 10 minutes\",\n+        )\n+        self.assertEqual(msg_type.value, \"text\")\n+        self.assertEqual(media_urls, [])\n+        self.assertEqual(media_types, [])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_share_chat_message_as_text_summary(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        message = SimpleNamespace(\n+            message_type=\"share_chat\",\n+            content='{\"chat_id\":\"oc_shared\",\"chat_name\":\"Platform Ops\"}',\n+            message_id=\"om_share_chat\",\n+        )\n+\n+        text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))\n+\n+        self.assertEqual(text, \"Shared chat: Platform Ops\\nChat ID: oc_shared\")\n+        self.assertEqual(msg_type.value, \"text\")\n+        self.assertEqual(media_urls, [])\n+        self.assertEqual(media_types, [])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_interactive_message_as_text_summary(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        message = SimpleNamespace(\n+            message_type=\"interactive\",\n+            content=json.dumps(\n+                {\n+                    \"card\": {\n+                        \"header\": {\"title\": {\"tag\": \"plain_text\", \"content\": \"Approval Request\"}},\n+                        \"elements\": [\n+                            {\"tag\": \"div\", \"text\": {\"tag\": \"plain_text\", \"content\": \"Requester: Alice\"}},\n+                            {\n+                                \"tag\": \"action\",\n+                                \"actions\": [\n+                                    {\"tag\": \"button\", \"text\": {\"tag\": \"plain_text\", \"content\": \"Approve\"}},\n+                                ],\n+                            },\n+                        ],\n+                    }\n+                }\n+            ),\n+            message_id=\"om_interactive\",\n+        )\n+\n+        text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))\n+\n+        self.assertEqual(text, \"Approval Request\\nRequester: Alice\\nApprove\\nActions: Approve\")\n+        self.assertEqual(msg_type.value, \"text\")\n+        self.assertEqual(media_urls, [])\n+        self.assertEqual(media_types, [])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_image_message_downloads_and_caches(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._download_feishu_image = AsyncMock(return_value=(\"/tmp/feishu-image.png\", \"image/png\"))\n+        message = SimpleNamespace(\n+            message_type=\"image\",\n+            content='{\"image_key\":\"img_123\"}',\n+            message_id=\"om_image\",\n+        )\n+\n+        text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))\n+\n+        self.assertEqual(text, \"\")\n+        self.assertEqual(msg_type.value, \"photo\")\n+        self.assertEqual(media_urls, [\"/tmp/feishu-image.png\"])\n+        self.assertEqual(media_types, [\"image/png\"])\n+        adapter._download_feishu_image.assert_awaited_once_with(\n+            message_id=\"om_image\",\n+            image_key=\"img_123\",\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_audio_message_downloads_and_caches(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._download_feishu_message_resource = AsyncMock(\n+            return_value=(\"/tmp/feishu-audio.ogg\", \"audio/ogg\")\n+        )\n+        message = SimpleNamespace(\n+            message_type=\"audio\",\n+            content='{\"file_key\":\"file_audio\",\"file_name\":\"voice.ogg\"}',\n+            message_id=\"om_audio\",\n+        )\n+\n+        text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))\n+\n+        self.assertEqual(text, \"\")\n+        self.assertEqual(msg_type.value, \"audio\")\n+        self.assertEqual(media_urls, [\"/tmp/feishu-audio.ogg\"])\n+        self.assertEqual(media_types, [\"audio/ogg\"])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_file_message_downloads_and_caches(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._download_feishu_message_resource = AsyncMock(\n+            return_value=(\"/tmp/doc_123_report.pdf\", \"application/pdf\")\n+        )\n+        message = SimpleNamespace(\n+            message_type=\"file\",\n+            content='{\"file_key\":\"file_doc\",\"file_name\":\"report.pdf\"}',\n+            message_id=\"om_file\",\n+        )\n+\n+        text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))\n+\n+        self.assertEqual(text, \"\")\n+        self.assertEqual(msg_type.value, \"document\")\n+        self.assertEqual(media_urls, [\"/tmp/doc_123_report.pdf\"])\n+        self.assertEqual(media_types, [\"application/pdf\"])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_media_message_with_image_mime_becomes_photo(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._download_feishu_message_resource = AsyncMock(\n+            return_value=(\"/tmp/feishu-media.jpg\", \"image/jpeg\")\n+        )\n+        message = SimpleNamespace(\n+            message_type=\"media\",\n+            content='{\"file_key\":\"file_media\",\"file_name\":\"photo.jpg\"}',\n+            message_id=\"om_media\",\n+        )\n+\n+        text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))\n+\n+        self.assertEqual(text, \"\")\n+        self.assertEqual(msg_type.value, \"photo\")\n+        self.assertEqual(media_urls, [\"/tmp/feishu-media.jpg\"])\n+        self.assertEqual(media_types, [\"image/jpeg\"])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_media_message_with_video_mime_becomes_video(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._download_feishu_message_resource = AsyncMock(\n+            return_value=(\"/tmp/feishu-video.mp4\", \"video/mp4\")\n+        )\n+        message = SimpleNamespace(\n+            message_type=\"media\",\n+            content='{\"file_key\":\"file_video\",\"file_name\":\"clip.mp4\"}',\n+            message_id=\"om_video\",\n+        )\n+\n+        text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))\n+\n+        self.assertEqual(text, \"\")\n+        self.assertEqual(msg_type.value, \"video\")\n+        self.assertEqual(media_urls, [\"/tmp/feishu-video.mp4\"])\n+        self.assertEqual(media_types, [\"video/mp4\"])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_text_from_raw_content_uses_relation_message_fallbacks(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+\n+        shared = adapter._extract_text_from_raw_content(\n+            msg_type=\"share_chat\",\n+            raw_content='{\"chat_id\":\"oc_shared\",\"chat_name\":\"Platform Ops\"}',\n+        )\n+        attachment = adapter._extract_text_from_raw_content(\n+            msg_type=\"file\",\n+            raw_content='{\"file_key\":\"file_1\",\"file_name\":\"report.pdf\"}',\n+        )\n+\n+        self.assertEqual(shared, \"Shared chat: Platform Ops\\nChat ID: oc_shared\")\n+        self.assertEqual(attachment, \"[Attachment: report.pdf]\")\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_text_message_starting_with_slash_becomes_command(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._dispatch_inbound_event = AsyncMock()\n+        adapter.get_chat_info = AsyncMock(\n+            return_value={\"chat_id\": \"oc_chat\", \"name\": \"Feishu DM\", \"type\": \"dm\"}\n+        )\n+        adapter._resolve_sender_profile = AsyncMock(\n+            return_value={\"user_id\": \"ou_user\", \"user_name\": \"张三\", \"user_id_alt\": None}\n+        )\n+        message = SimpleNamespace(\n+            chat_id=\"oc_chat\",\n+            thread_id=None,\n+            parent_id=None,\n+            upper_message_id=None,\n+            message_type=\"text\",\n+            content='{\"text\":\"/help test\"}',\n+            message_id=\"om_command\",\n+        )\n+\n+        asyncio.run(\n+            adapter._process_inbound_message(\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+                chat_type=\"p2p\",\n+                message_id=\"om_command\",\n+            )\n+        )\n+\n+        event = adapter._dispatch_inbound_event.await_args.args[0]\n+        self.assertEqual(event.message_type.value, \"command\")\n+        self.assertEqual(event.text, \"/help test\")\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_extract_text_file_injects_content(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        with tempfile.NamedTemporaryFile(\"w\", suffix=\".txt\", delete=False) as tmp:\n+            tmp.write(\"hello from feishu\")\n+            path = tmp.name\n+\n+        try:\n+            text = asyncio.run(adapter._maybe_extract_text_document(path, \"text/plain\"))\n+        finally:\n+            os.unlink(path)\n+\n+        self.assertIn(\"hello from feishu\", text)\n+        self.assertIn(\"[Content of\", text)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_message_event_submits_to_adapter_loop(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._loop = object()\n+\n+        message = SimpleNamespace(\n+            message_id=\"om_text\",\n+            chat_type=\"p2p\",\n+            chat_id=\"oc_chat\",\n+            message_type=\"text\",\n+            content='{\"text\":\"hello\"}',\n+        )\n+        sender_id = SimpleNamespace(open_id=\"ou_user\", user_id=None, union_id=None)\n+        sender = SimpleNamespace(sender_id=sender_id, sender_type=\"user\")\n+        data = SimpleNamespace(event=SimpleNamespace(message=message, sender=sender))\n+\n+        future = SimpleNamespace(add_done_callback=lambda *_args, **_kwargs: None)\n+        def _submit(coro, _loop):\n+            coro.close()\n+            return future\n+\n+        with patch(\"gateway.platforms.feishu.asyncio.run_coroutine_threadsafe\", side_effect=_submit) as submit:\n+            adapter._on_message_event(data)\n+\n+        self.assertTrue(submit.called)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_process_inbound_message_uses_event_sender_identity_only(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.base import MessageType\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._dispatch_inbound_event = AsyncMock()\n+        # Sender name now comes from the contact API; mock it to return a known value.\n+        adapter._resolve_sender_name_from_api = AsyncMock(return_value=\"张三\")\n+        adapter.get_chat_info = AsyncMock(\n+            return_value={\"chat_id\": \"oc_chat\", \"name\": \"Feishu DM\", \"type\": \"dm\"}\n+        )\n+        message = SimpleNamespace(\n+            chat_id=\"oc_chat\",\n+            thread_id=None,\n+            message_type=\"text\",\n+            content='{\"text\":\"hello\"}',\n+            message_id=\"om_text\",\n+        )\n+        sender_id = SimpleNamespace(\n+            open_id=\"ou_user\",\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+\n+        asyncio.run(\n+            adapter._process_inbound_message(\n+                data=data,\n+                message=message,\n+                sender_id=sender_id,\n+                chat_type=\"p2p\",\n+                message_id=\"om_text\",\n+            )\n+        )\n+\n+        adapter._dispatch_inbound_event.assert_awaited_once()\n+        event = adapter._dispatch_inbound_event.await_args.args[0]\n+        self.assertEqual(event.message_type, MessageType.TEXT)\n+        self.assertEqual(event.source.user_id, \"ou_user\")\n+        self.assertEqual(event.source.user_name, \"张三\")\n+        self.assertEqual(event.source.user_id_alt, \"on_union\")\n+        self.assertEqual(event.source.chat_name, \"Feishu DM\")\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_text_batch_merges_rapid_messages_into_single_event(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.base import MessageEvent, MessageType\n+        from gateway.platforms.feishu import FeishuAdapter\n+        from gateway.session import SessionSource\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter.handle_message = AsyncMock()\n+        source = SessionSource(\n+            platform=adapter.platform,\n+            chat_id=\"oc_chat\",\n+            chat_name=\"Feishu DM\",\n+            chat_type=\"dm\",\n+            user_id=\"ou_user\",\n+            user_name=\"张三\",\n+        )\n+\n+        async def _sleep(_delay):\n+            return None\n+\n+        async def _run() -> None:\n+            with patch(\"gateway.platforms.feishu.asyncio.sleep\", side_effect=_sleep):\n+                await adapter._dispatch_inbound_event(\n+                    MessageEvent(text=\"A\", message_type=MessageType.TEXT, source=source, message_id=\"om_1\")\n+                )\n+                await adapter._dispatch_inbound_event(\n+                    MessageEvent(text=\"B\", message_type=MessageType.TEXT, source=source, message_id=\"om_2\")\n+                )\n+                pending = list(adapter._pending_text_batch_tasks.values())\n+                self.assertEqual(len(pending), 1)\n+                await asyncio.gather(*pending, return_exceptions=True)\n+\n+        asyncio.run(_run())\n+\n+        adapter.handle_message.assert_awaited_once()\n+        event = adapter.handle_message.await_args.args[0]\n+        self.assertEqual(event.text, \"A\\nB\")\n+        self.assertEqual(event.message_type, MessageType.TEXT)\n+\n+    @patch.dict(\n+        os.environ,\n+        {\n+            \"HERMES_FEISHU_TEXT_BATCH_MAX_MESSAGES\": \"2\",\n+        },\n+        clear=True,\n+    )\n+    def test_text_batch_flushes_when_message_count_limit_is_hit(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.base import MessageEvent, MessageType\n+        from gateway.platforms.feishu import FeishuAdapter\n+        from gateway.session import SessionSource\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter.handle_message = AsyncMock()\n+        source = SessionSource(\n+            platform=adapter.platform,\n+            chat_id=\"oc_chat\",\n+            chat_name=\"Feishu DM\",\n+            chat_type=\"dm\",\n+            user_id=\"ou_user\",\n+            user_name=\"张三\",\n+        )\n+\n+        async def _sleep(_delay):\n+            return None\n+\n+        async def _run() -> None:\n+            with patch(\"gateway.platforms.feishu.asyncio.sleep\", side_effect=_sleep):\n+                await adapter._dispatch_inbound_event(\n+                    MessageEvent(text=\"A\", message_type=MessageType.TEXT, source=source, message_id=\"om_1\")\n+                )\n+                await adapter._dispatch_inbound_event(\n+                    MessageEvent(text=\"B\", message_type=MessageType.TEXT, source=source, message_id=\"om_2\")\n+                )\n+                await adapter._dispatch_inbound_event(\n+                    MessageEvent(text=\"C\", message_type=MessageType.TEXT, source=source, message_id=\"om_3\")\n+                )\n+                pending = list(adapter._pending_text_batch_tasks.values())\n+                self.assertEqual(len(pending), 1)\n+                await asyncio.gather(*pending, return_exceptions=True)\n+\n+        asyncio.run(_run())\n+\n+        self.assertEqual(adapter.handle_message.await_count, 2)\n+        first = adapter.handle_message.await_args_list[0].args[0]\n+        second = adapter.handle_message.await_args_list[1].args[0]\n+        self.assertEqual(first.text, \"A\\nB\")\n+        self.assertEqual(second.text, \"C\")\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_media_batch_merges_rapid_photo_messages(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.base import MessageEvent, MessageType\n+        from gateway.platforms.feishu import FeishuAdapter\n+        from gateway.session import SessionSource\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter.handle_message = AsyncMock()\n+        source = SessionSource(\n+            platform=adapter.platform,\n+            chat_id=\"oc_chat\",\n+            chat_name=\"Feishu DM\",\n+            chat_type=\"dm\",\n+            user_id=\"ou_user\",\n+            user_name=\"张三\",\n+        )\n+\n+        async def _sleep(_delay):\n+            return None\n+\n+        async def _run() -> None:\n+            with patch(\"gateway.platforms.feishu.asyncio.sleep\", side_effect=_sleep):\n+                await adapter._dispatch_inbound_event(\n+                    MessageEvent(\n+                        text=\"第一张\",\n+                        message_type=MessageType.PHOTO,\n+                        source=source,\n+                        message_id=\"om_p1\",\n+                        media_urls=[\"/tmp/a.png\"],\n+                        media_types=[\"image/png\"],\n+                    )\n+                )\n+                await adapter._dispatch_inbound_event(\n+                    MessageEvent(\n+                        text=\"第二张\",\n+                        message_type=MessageType.PHOTO,\n+                        source=source,\n+                        message_id=\"om_p2\",\n+                        media_urls=[\"/tmp/b.png\"],\n+                        media_types=[\"image/png\"],\n+                    )\n+                )\n+                pending = list(adapter._pending_media_batch_tasks.values())\n+                self.assertEqual(len(pending), 1)\n+                await asyncio.gather(*pending, return_exceptions=True)\n+\n+        asyncio.run(_run())\n+\n+        adapter.handle_message.assert_awaited_once()\n+        event = adapter.handle_message.await_args.args[0]\n+        self.assertEqual(event.media_urls, [\"/tmp/a.png\", \"/tmp/b.png\"])\n+        self.assertIn(\"第一张\", event.text)\n+        self.assertIn(\"第二张\", event.text)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_image_downloads_then_uses_native_image_send(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter.send_image_file = AsyncMock(return_value=SimpleNamespace(success=True, message_id=\"om_img\"))\n+\n+        async def _run():\n+            with patch(\"gateway.platforms.feishu.cache_image_from_url\", new=AsyncMock(return_value=\"/tmp/cached.png\")):\n+                return await adapter.send_image(\"oc_chat\", \"https://example.com/cat.png\", caption=\"cat\")\n+\n+        result = asyncio.run(_run())\n+\n+        self.assertTrue(result.success)\n+        adapter.send_image_file.assert_awaited_once()\n+        self.assertEqual(adapter.send_image_file.await_args.kwargs[\"image_path\"], \"/tmp/cached.png\")\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_animation_degrades_to_document_send(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter.send_document = AsyncMock(return_value=SimpleNamespace(success=True, message_id=\"om_gif\"))\n+\n+        async def _run():\n+            with patch.object(\n+                adapter,\n+                \"_download_remote_document\",\n+                new=AsyncMock(return_value=(\"/tmp/anim.gif\", \"anim.gif\")),\n+            ):\n+                return await adapter.send_animation(\"oc_chat\", \"https://example.com/anim.gif\", caption=\"look\")\n+\n+        result = asyncio.run(_run())\n+\n+        self.assertTrue(result.success)\n+        adapter.send_document.assert_awaited_once()\n+        caption = adapter.send_document.await_args.kwargs[\"caption\"]\n+        self.assertIn(\"GIF downgraded to file\", caption)\n+        self.assertIn(\"look\", caption)\n+\n+    def test_dedup_state_persists_across_adapter_restart(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        with tempfile.TemporaryDirectory() as temp_home:\n+            with patch.dict(os.environ, {\"HERMES_HOME\": temp_home}, clear=False):\n+                first = FeishuAdapter(PlatformConfig())\n+                self.assertFalse(first._is_duplicate(\"om_same\"))\n+                second = FeishuAdapter(PlatformConfig())\n+                self.assertTrue(second._is_duplicate(\"om_same\"))\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_process_inbound_group_message_keeps_group_type_when_chat_lookup_falls_back(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._dispatch_inbound_event = AsyncMock()\n+        adapter.get_chat_info = AsyncMock(\n+            return_value={\"chat_id\": \"oc_group\", \"name\": \"oc_group\", \"type\": \"dm\"}\n+        )\n+        adapter._resolve_sender_profile = AsyncMock(\n+            return_value={\"user_id\": \"ou_user\", \"user_name\": \"张三\", \"user_id_alt\": None}\n+        )\n+        message = SimpleNamespace(\n+            chat_id=\"oc_group\",\n+            thread_id=None,\n+            message_type=\"text\",\n+            content='{\"text\":\"hello group\"}',\n+            message_id=\"om_group_text\",\n+        )\n+        sender_id = SimpleNamespace(open_id=\"ou_user\", user_id=None, union_id=None)\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+                chat_type=\"group\",\n+                message_id=\"om_group_text\",\n+            )\n+        )\n+\n+        event = adapter._dispatch_inbound_event.await_args.args[0]\n+        self.assertEqual(event.source.chat_type, \"group\")\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_process_inbound_message_fetches_reply_to_text(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._dispatch_inbound_event = AsyncMock()\n+        adapter.get_chat_info = AsyncMock(\n+            return_value={\"chat_id\": \"oc_chat\", \"name\": \"Feishu DM\", \"type\": \"dm\"}\n+        )\n+        adapter._resolve_sender_profile = AsyncMock(\n+            return_value={\"user_id\": \"ou_user\", \"user_name\": \"张三\", \"user_id_alt\": None}\n+        )\n+        adapter._fetch_message_text = AsyncMock(return_value=\"父消息内容\")\n+        message = SimpleNamespace(\n+            chat_id=\"oc_chat\",\n+            thread_id=None,\n+            parent_id=\"om_parent\",\n+            upper_message_id=None,\n+            message_type=\"text\",\n+            content='{\"text\":\"reply\"}',\n+            message_id=\"om_reply\",\n+        )\n+\n+        asyncio.run(\n+            adapter._process_inbound_message(\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+                chat_type=\"p2p\",\n+                message_id=\"om_reply\",\n+            )\n+        )\n+\n+        event = adapter._dispatch_inbound_event.await_args.args[0]\n+        self.assertEqual(event.reply_to_message_id, \"om_parent\")\n+        self.assertEqual(event.reply_to_text, \"父消息内容\")\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_replies_in_thread_when_thread_metadata_present(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _ReplyAPI:\n+            def reply(self, request):\n+                captured[\"request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_reply\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    message=_ReplyAPI(),\n+                )\n+            )\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(\n+                adapter.send(\n+                    chat_id=\"oc_chat\",\n+                    content=\"hello\",\n+                    reply_to=\"om_parent\",\n+                    metadata={\"thread_id\": \"omt-thread\"},\n+                )\n+            )\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(result.message_id, \"om_reply\")\n+        self.assertTrue(captured[\"request\"].request_body.reply_in_thread)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_retries_transient_failure(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {\"attempts\": 0}\n+        sleeps = []\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"attempts\"] += 1\n+                captured[\"request\"] = request\n+                if captured[\"attempts\"] == 1:\n+                    raise OSError(\"temporary send failure\")\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_retry\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    message=_MessageAPI(),\n+                )\n+            )\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        async def _sleep(delay):\n+            sleeps.append(delay)\n+\n+        with (\n+            patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct),\n+            patch(\"gateway.platforms.feishu.asyncio.sleep\", side_effect=_sleep),\n+        ):\n+            result = asyncio.run(adapter.send(chat_id=\"oc_chat\", content=\"hello retry\"))\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(result.message_id, \"om_retry\")\n+        self.assertEqual(captured[\"attempts\"], 2)\n+        self.assertEqual(sleeps, [1])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_does_not_retry_deterministic_api_failure(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {\"attempts\": 0}\n+        sleeps = []\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"attempts\"] += 1\n+                return SimpleNamespace(\n+                    success=lambda: False,\n+                    code=400,\n+                    msg=\"bad request\",\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    message=_MessageAPI(),\n+                )\n+            )\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        async def _sleep(delay):\n+            sleeps.append(delay)\n+\n+        with (\n+            patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct),\n+            patch(\"gateway.platforms.feishu.asyncio.sleep\", side_effect=_sleep),\n+        ):\n+            result = asyncio.run(adapter.send(chat_id=\"oc_chat\", content=\"bad payload\"))\n+\n+        self.assertFalse(result.success)\n+        self.assertEqual(result.error, \"[400] bad request\")\n+        self.assertEqual(captured[\"attempts\"], 1)\n+        self.assertEqual(sleeps, [])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_document_reply_uses_thread_flag(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _FileAPI:\n+            def create(self, request):\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(file_key=\"file_123\"),\n+                )\n+\n+        class _MessageAPI:\n+            def reply(self, request):\n+                captured[\"request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_file_reply\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    file=_FileAPI(),\n+                    message=_MessageAPI(),\n+                )\n+            )\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with tempfile.NamedTemporaryFile(\"wb\", suffix=\".pdf\", delete=False) as tmp:\n+            tmp.write(b\"%PDF-1.4 test\")\n+            file_path = tmp.name\n+\n+        try:\n+            with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+                result = asyncio.run(\n+                    adapter.send_document(\n+                        chat_id=\"oc_chat\",\n+                        file_path=file_path,\n+                        reply_to=\"om_parent\",\n+                        metadata={\"thread_id\": \"omt-thread\"},\n+                    )\n+                )\n+        finally:\n+            os.unlink(file_path)\n+\n+        self.assertTrue(result.success)\n+        self.assertTrue(captured[\"request\"].request_body.reply_in_thread)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_document_uploads_file_and_sends_file_message(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _FileAPI:\n+            def create(self, request):\n+                captured[\"upload_request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(file_key=\"file_123\"),\n+                )\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"message_request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_file_msg\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    file=_FileAPI(),\n+                    message=_MessageAPI(),\n+                )\n+            )\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with tempfile.NamedTemporaryFile(\"wb\", suffix=\".pdf\", delete=False) as tmp:\n+            tmp.write(b\"%PDF-1.4 test\")\n+            file_path = tmp.name\n+\n+        try:\n+            with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+                result = asyncio.run(adapter.send_document(chat_id=\"oc_chat\", file_path=file_path))\n+        finally:\n+            os.unlink(file_path)\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(result.message_id, \"om_file_msg\")\n+        self.assertEqual(captured[\"upload_request\"].request_body.file_type, \"pdf\")\n+        self.assertEqual(\n+            captured[\"message_request\"].request_body.content,\n+            '{\"file_key\": \"file_123\"}',\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_document_with_caption_uses_single_post_message(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _FileAPI:\n+            def create(self, request):\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(file_key=\"file_123\"),\n+                )\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"message_request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_post_msg\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    file=_FileAPI(),\n+                    message=_MessageAPI(),\n+                )\n+            )\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with tempfile.NamedTemporaryFile(\"wb\", suffix=\".pdf\", delete=False) as tmp:\n+            tmp.write(b\"%PDF-1.4 test\")\n+            file_path = tmp.name\n+\n+        try:\n+            with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+                result = asyncio.run(\n+                    adapter.send_document(chat_id=\"oc_chat\", file_path=file_path, caption=\"报告请看\")\n+                )\n+        finally:\n+            os.unlink(file_path)\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(captured[\"message_request\"].request_body.msg_type, \"post\")\n+        self.assertIn('\"tag\": \"media\"', captured[\"message_request\"].request_body.content)\n+        self.assertIn('\"file_key\": \"file_123\"', captured[\"message_request\"].request_body.content)\n+        self.assertIn(\"报告请看\", captured[\"message_request\"].request_body.content)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_image_file_uploads_image_and_sends_image_message(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _ImageAPI:\n+            def create(self, request):\n+                captured[\"upload_request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(image_key=\"img_123\"),\n+                )\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"message_request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_image_msg\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    image=_ImageAPI(),\n+                    message=_MessageAPI(),\n+                )\n+            )\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with tempfile.NamedTemporaryFile(\"wb\", suffix=\".png\", delete=False) as tmp:\n+            tmp.write(b\"\\x89PNG\\r\\n\\x1a\\n\")\n+            image_path = tmp.name\n+\n+        try:\n+            with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+                result = asyncio.run(adapter.send_image_file(chat_id=\"oc_chat\", image_path=image_path))\n+        finally:\n+            os.unlink(image_path)\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(result.message_id, \"om_image_msg\")\n+        self.assertEqual(captured[\"upload_request\"].request_body.image_type, \"message\")\n+        self.assertEqual(\n+            captured[\"message_request\"].request_body.content,\n+            '{\"image_key\": \"img_123\"}',\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_image_file_with_caption_uses_single_post_message(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _ImageAPI:\n+            def create(self, request):\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(image_key=\"img_123\"),\n+                )\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"message_request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_post_img\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    image=_ImageAPI(),\n+                    message=_MessageAPI(),\n+                )\n+            )\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with tempfile.NamedTemporaryFile(\"wb\", suffix=\".png\", delete=False) as tmp:\n+            tmp.write(b\"\\x89PNG\\r\\n\\x1a\\n\")\n+            image_path = tmp.name\n+\n+        try:\n+            with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+                result = asyncio.run(\n+                    adapter.send_image_file(chat_id=\"oc_chat\", image_path=image_path, caption=\"截图说明\")\n+                )\n+        finally:\n+            os.unlink(image_path)\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(captured[\"message_request\"].request_body.msg_type, \"post\")\n+        self.assertIn('\"tag\": \"img\"', captured[\"message_request\"].request_body.content)\n+        self.assertIn('\"image_key\": \"img_123\"', captured[\"message_request\"].request_body.content)\n+        self.assertIn(\"截图说明\", captured[\"message_request\"].request_body.content)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_video_uploads_file_and_sends_media_message(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _FileAPI:\n+            def create(self, request):\n+                captured[\"upload_request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(file_key=\"file_video_123\"),\n+                )\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"message_request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_video_msg\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    file=_FileAPI(),\n+                    message=_MessageAPI(),\n+                )\n+            )\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with tempfile.NamedTemporaryFile(\"wb\", suffix=\".mp4\", delete=False) as tmp:\n+            tmp.write(b\"\\x00\\x00\\x00\\x18ftypmp42\")\n+            video_path = tmp.name\n+\n+        try:\n+            with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+                result = asyncio.run(adapter.send_video(chat_id=\"oc_chat\", video_path=video_path))\n+        finally:\n+            os.unlink(video_path)\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(captured[\"upload_request\"].request_body.file_type, \"mp4\")\n+        self.assertEqual(captured[\"message_request\"].request_body.msg_type, \"media\")\n+        self.assertEqual(captured[\"message_request\"].request_body.content, '{\"file_key\": \"file_video_123\"}')\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_voice_uploads_opus_and_sends_audio_message(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _FileAPI:\n+            def create(self, request):\n+                captured[\"upload_request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(file_key=\"file_audio_123\"),\n+                )\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"message_request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_audio_msg\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    file=_FileAPI(),\n+                    message=_MessageAPI(),\n+                )\n+            )\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        with tempfile.NamedTemporaryFile(\"wb\", suffix=\".opus\", delete=False) as tmp:\n+            tmp.write(b\"opus\")\n+            audio_path = tmp.name\n+\n+        try:\n+            with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+                result = asyncio.run(adapter.send_voice(chat_id=\"oc_chat\", audio_path=audio_path))\n+        finally:\n+            os.unlink(audio_path)\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(captured[\"upload_request\"].request_body.file_type, \"opus\")\n+        self.assertEqual(captured[\"message_request\"].request_body.msg_type, \"audio\")\n+        self.assertEqual(captured[\"message_request\"].request_body.content, '{\"file_key\": \"file_audio_123\"}')\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_build_post_payload_extracts_title_and_links(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        payload = json.loads(adapter._build_post_payload(\"# 标题\\n访问 [文档](https://example.com)\"))\n+\n+        elements = payload[\"zh_cn\"][\"content\"][0]\n+        self.assertEqual(elements, [{\"tag\": \"md\", \"text\": \"# 标题\\n访问 [文档](https://example.com)\"}])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_build_post_payload_wraps_markdown_in_md_tag(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        payload = json.loads(\n+            adapter._build_post_payload(\"支持 **粗体**、*斜体* 和 `代码`\")\n+        )\n+\n+        elements = payload[\"zh_cn\"][\"content\"][0]\n+        self.assertEqual(\n+            elements,\n+            [\n+                {\"tag\": \"md\", \"text\": \"支持 **粗体**、*斜体* 和 `代码`\"},\n+            ],\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_build_post_payload_keeps_full_markdown_text(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        payload = json.loads(\n+            adapter._build_post_payload(\n+                \"---\\n1. 第一项\\n  2. 子项\\n- 外层\\n  - 内层\\n<u>下划线</u> 和 ~~删除线~~\"\n+            )\n+        )\n+\n+        rows = payload[\"zh_cn\"][\"content\"]\n+        self.assertEqual(\n+            rows,\n+            [[{\"tag\": \"md\", \"text\": \"---\\n1. 第一项\\n  2. 子项\\n- 外层\\n  - 内层\\n<u>下划线</u> 和 ~~删除线~~\"}]],\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_uses_post_for_inline_markdown(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_markdown\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    message=_MessageAPI(),\n+                )\n+            )\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(\n+                adapter.send(\n+                    chat_id=\"oc_chat\",\n+                    content=\"可以用 **粗体** 和 *斜体*。\",\n+                )\n+            )\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(captured[\"request\"].request_body.msg_type, \"post\")\n+        payload = json.loads(captured[\"request\"].request_body.content)\n+        elements = payload[\"zh_cn\"][\"content\"][0]\n+        self.assertEqual(elements, [{\"tag\": \"md\", \"text\": \"可以用 **粗体** 和 *斜体*。\"}])\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_falls_back_to_text_when_post_payload_is_rejected(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {\"calls\": []}\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"calls\"].append(request)\n+                if len(captured[\"calls\"]) == 1:\n+                    raise RuntimeError(\"content format of the post type is incorrect\")\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_plain\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    message=_MessageAPI(),\n+                )\n+            )\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(\n+                adapter.send(\n+                    chat_id=\"oc_chat\",\n+                    content=\"可以用 **粗体** 和 *斜体*。\",\n+                )\n+            )\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(captured[\"calls\"][0].request_body.msg_type, \"post\")\n+        self.assertEqual(captured[\"calls\"][1].request_body.msg_type, \"text\")\n+        self.assertEqual(\n+            captured[\"calls\"][1].request_body.content,\n+            json.dumps({\"text\": \"可以用 粗体 和 斜体。\"}, ensure_ascii=False),\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_falls_back_to_text_when_post_response_is_unsuccessful(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {\"calls\": []}\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"calls\"].append(request)\n+                if len(captured[\"calls\"]) == 1:\n+                    return SimpleNamespace(success=lambda: False, code=230001, msg=\"content format of the post type is incorrect\")\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_plain_response\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    message=_MessageAPI(),\n+                )\n+            )\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(\n+                adapter.send(\n+                    chat_id=\"oc_chat\",\n+                    content=\"可以用 **粗体** 和 *斜体*。\",\n+                )\n+            )\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(captured[\"calls\"][0].request_body.msg_type, \"post\")\n+        self.assertEqual(captured[\"calls\"][1].request_body.msg_type, \"text\")\n+        self.assertEqual(\n+            captured[\"calls\"][1].request_body.content,\n+            json.dumps({\"text\": \"可以用 粗体 和 斜体。\"}, ensure_ascii=False),\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_uses_post_for_advanced_markdown_lines(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_markdown_advanced\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    message=_MessageAPI(),\n+                )\n+            )\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(\n+                adapter.send(\n+                    chat_id=\"oc_chat\",\n+                    content=\"---\\n1. 第一项\\n<u>下划线</u>\\n~~删除线~~\",\n+                )\n+            )\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(captured[\"request\"].request_body.msg_type, \"post\")\n+        payload = json.loads(captured[\"request\"].request_body.content)\n+        rows = payload[\"zh_cn\"][\"content\"]\n+        self.assertEqual(\n+            rows,\n+            [[{\"tag\": \"md\", \"text\": \"---\\n1. 第一项\\n<u>下划线</u>\\n~~删除线~~\"}]],\n+        )\n+\n+\n[redacted:email](_HAS_LARK_OAPI, \"lark-oapi not installed\")\n+class TestWebhookSecurity(unittest.TestCase):\n+    \"\"\"Tests for webhook signature verification, rate limiting, and body size limits.\"\"\"\n+\n+    def _make_adapter(self, encrypt_key: str = \"\") -> \"FeishuAdapter\":\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        with patch.dict(os.environ, {\"FEISHU_APP_ID\": \"cli\", \"FEISHU_APP_SECRET\": \"sec\", \"FEISHU_ENCRYPT_KEY\": encrypt_key}, clear=True):\n+            return FeishuAdapter(PlatformConfig())\n+\n+    def test_signature_valid_passes(self):\n+        import hashlib\n+        from gateway.platforms.feishu import FeishuAdapter\n+        from gateway.config import PlatformConfig\n+\n+        encrypt_key = \"test_secret\"\n+        adapter = self._make_adapter(encrypt_key)\n+        body = b'{\"type\":\"event\"}'\n+        timestamp = \"1700000000\"\n+        nonce = \"abc123\"\n+        content = f\"{timestamp}{nonce}{encrypt_key}\" + body.decode(\"utf-8\")\n+        sig = hashlib.sha256(content.encode(\"utf-8\")).hexdigest()\n+        headers = {\"x-lark-request-timestamp\": timestamp, \"x-lark-request-nonce\": nonce, \"x-lark-signature\": sig}\n+        self.assertTrue(adapter._is_webhook_signature_valid(headers, body))\n+\n+    def test_signature_invalid_rejected(self):\n+        adapter = self._make_adapter(\"test_secret\")\n+        headers = {\n+            \"x-lark-request-timestamp\": \"1700000000\",\n+            \"x-lark-request-nonce\": \"abc\",\n+            \"x-lark-signature\": \"deadbeef\" * 8,\n+        }\n+        self.assertFalse(adapter._is_webhook_signature_valid(headers, b'{\"type\":\"event\"}'))\n+\n+    def test_signature_missing_headers_rejected(self):\n+        adapter = self._make_adapter(\"test_secret\")\n+        self.assertFalse(adapter._is_webhook_signature_valid({}, b'{}'))\n+\n+    def test_rate_limit_allows_requests_within_window(self):\n+        adapter = self._make_adapter()\n+        for _ in range(5):\n+            self.assertTrue(adapter._check_webhook_rate_limit(\"10.0.0.1\"))\n+\n+    def test_rate_limit_blocks_after_exceeding_max(self):\n+        from gateway.platforms.feishu import _FEISHU_WEBHOOK_RATE_LIMIT_MAX\n+        adapter = self._make_adapter()\n+        for _ in range(_FEISHU_WEBHOOK_RATE_LIMIT_MAX):\n+            adapter._check_webhook_rate_limit(\"10.0.0.2\")\n+        self.assertFalse(adapter._check_webhook_rate_limit(\"10.0.0.2\"))\n+\n+    def test_rate_limit_resets_after_window_expires(self):\n+        from gateway.platforms.feishu import _FEISHU_WEBHOOK_RATE_LIMIT_MAX, _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS\n+        adapter = self._make_adapter()\n+        ip = \"10.0.0.3\"\n+        for _ in range(_FEISHU_WEBHOOK_RATE_LIMIT_MAX):\n+            adapter._check_webhook_rate_limit(ip)\n+        self.assertFalse(adapter._check_webhook_rate_limit(ip))\n+        # Simulate window expiry by backdating the stored entry.\n+        count, window_start = adapter._webhook_rate_counts[ip]\n+        adapter._webhook_rate_counts[ip] = (count, window_start - _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS - 1)\n+        self.assertTrue(adapter._check_webhook_rate_limit(ip))\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_webhook_request_rejects_oversized_body(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter, _FEISHU_WEBHOOK_MAX_BODY_BYTES\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        # Simulate a request whose Content-Length already signals oversize.\n+        request = SimpleNamespace(\n+            remote=\"127.0.0.1\",\n+            content_length=_FEISHU_WEBHOOK_MAX_BODY_BYTES + 1,\n+        )\n+        response = asyncio.run(adapter._handle_webhook_request(request))\n+        self.assertEqual(response.status, 413)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_webhook_request_rejects_invalid_json(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        request = SimpleNamespace(\n+            remote=\"127.0.0.1\",\n+            content_length=None,\n+            read=AsyncMock(return_value=b\"not-json\"),\n+        )\n+        response = asyncio.run(adapter._handle_webhook_request(request))\n+        self.assertEqual(response.status, 400)\n+\n+    @patch.dict(os.environ, {\"FEISHU_ENCRYPT_KEY\": \"secret\"}, clear=True)\n+    def test_webhook_request_rejects_bad_signature(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        body = json.dumps({\"header\": {\"event_type\": \"im.message.receive_v1\"}}).encode()\n+        request = SimpleNamespace(\n+            remote=\"127.0.0.1\",\n+            content_length=None,\n+            headers={\"x-lark-request-timestamp\": \"123\", \"x-lark-request-nonce\": \"abc\", \"x-lark-signature\": \"bad\"},\n+            read=AsyncMock(return_value=body),\n+        )\n+        response = asyncio.run(adapter._handle_webhook_request(request))\n+        self.assertEqual(response.status, 401)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_webhook_url_verification_challenge_passes_without_signature(self):\n+        \"\"\"Challenge requests must succeed even when no encrypt_key is set.\"\"\"\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        body = json.dumps({\"type\": \"url_verification\", \"challenge\": \"test_challenge_token\"}).encode()\n+        request = SimpleNamespace(\n+            remote=\"127.0.0.1\",\n+            content_length=None,\n+            read=AsyncMock(return_value=body),\n+        )\n+        response = asyncio.run(adapter._handle_webhook_request(request))\n+        self.assertEqual(response.status, 200)\n+        self.assertIn(b\"test_challenge_token\", response.body)\n+\n+\n+class TestDedupTTL(unittest.TestCase):\n+    \"\"\"Tests for TTL-aware deduplication.\"\"\"\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_duplicate_within_ttl_is_rejected(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        with patch.object(adapter, \"_persist_seen_message_ids\"):\n+            adapter._seen_message_ids = {\"om_dup\": time.time()}\n+            adapter._seen_message_order = [\"om_dup\"]\n+            self.assertTrue(adapter._is_duplicate(\"om_dup\"))\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_expired_entry_is_not_considered_duplicate(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter, _FEISHU_DEDUP_TTL_SECONDS\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        # Plant an entry that expired well past the TTL.\n+        stale_ts = time.time() - _FEISHU_DEDUP_TTL_SECONDS - 60\n+        adapter._seen_message_ids = {\"om_old\": stale_ts}\n+        adapter._seen_message_order = [\"om_old\"]\n+        with patch.object(adapter, \"_persist_seen_message_ids\"):\n+            self.assertFalse(adapter._is_duplicate(\"om_old\"))\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_persist_saves_timestamps_as_dict(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        ts = time.time()\n+        adapter._seen_message_ids = {\"om_ts1\": ts}\n+        adapter._seen_message_order = [\"om_ts1\"]\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            adapter._dedup_state_path = Path(tmpdir) / \"dedup.json\"\n+            adapter._persist_seen_message_ids()\n+            saved = json.loads(adapter._dedup_state_path.read_text())\n+        self.assertIsInstance(saved[\"message_ids\"], dict)\n+        self.assertAlmostEqual(saved[\"message_ids\"][\"om_ts1\"], ts, places=1)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_load_backward_compat_list_format(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            path = Path(tmpdir) / \"dedup.json\"\n+            path.write_text(json.dumps({\"message_ids\": [\"om_a\", \"om_b\"]}), encoding=\"utf-8\")\n+            adapter._dedup_state_path = path\n+            adapter._load_seen_message_ids()\n+        self.assertIn(\"om_a\", adapter._seen_message_ids)\n+        self.assertIn(\"om_b\", adapter._seen_message_ids)\n+\n+\n+class TestGroupMentionAtAll(unittest.TestCase):\n+    \"\"\"Tests for @_all (Feishu @everyone) group mention routing.\"\"\"\n+\n+    @patch.dict(os.environ, {\"FEISHU_GROUP_POLICY\": \"open\"}, clear=True)\n+    def test_at_all_in_content_accepts_without_explicit_bot_mention(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        message = SimpleNamespace(\n+            content='{\"text\":\"@_all 请注意\"}',\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+\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):\n+        \"\"\"@_all bypasses mention gating but NOT the allowlist policy.\"\"\"\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\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+        # 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+\n+\n[redacted:email](_HAS_LARK_OAPI, \"lark-oapi not installed\")\n+class TestSenderNameResolution(unittest.TestCase):\n+    \"\"\"Tests for _resolve_sender_name_from_api.\"\"\"\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_returns_none_when_client_is_none(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._client = None\n+        result = asyncio.run(adapter._resolve_sender_name_from_api(\"ou_abc\"))\n+        self.assertIsNone(result)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_returns_cached_name_within_ttl(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        adapter._client = SimpleNamespace()\n+        future_expire = time.time() + 600\n+        adapter._sender_name_cache[\"ou_cached\"] = (\"Alice\", future_expire)\n+        result = asyncio.run(adapter._resolve_sender_name_from_api(\"ou_cached\"))\n+        self.assertEqual(result, \"Alice\")\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_fetches_and_caches_name_from_api(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        user_obj = SimpleNamespace(name=\"Bob\", display_name=None, nickname=None, en_name=None)\n+        mock_response = SimpleNamespace(\n+            success=lambda: True,\n+            data=SimpleNamespace(user=user_obj),\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        class _ContactAPI:\n+            def get(self, request):\n+                return mock_response\n+\n+        adapter._client = SimpleNamespace(\n+            contact=SimpleNamespace(v3=SimpleNamespace(user=_ContactAPI()))\n+        )\n+\n+        with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+            result = asyncio.run(adapter._resolve_sender_name_from_api(\"ou_bob\"))\n+\n+        self.assertEqual(result, \"Bob\")\n+        self.assertIn(\"ou_bob\", adapter._sender_name_cache)\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_expired_cache_triggers_new_api_call(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        # Expired cache entry.\n+        adapter._sender_name_cache[\"ou_expired\"] = (\"OldName\", time.time() - 1)\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        user_obj = SimpleNamespace(name=\"NewName\", display_name=None, nickname=None, en_name=None)\n+\n+        class _ContactAPI:\n+            def get(self, request):\n+                return SimpleNamespace(success=lambda: True, data=SimpleNamespace(user=user_obj))\n+\n+        adapter._client = SimpleNamespace(\n+            contact=SimpleNamespace(v3=SimpleNamespace(user=_ContactAPI()))\n+        )\n+\n+        with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+            result = asyncio.run(adapter._resolve_sender_name_from_api(\"ou_expired\"))\n+\n+        self.assertEqual(result, \"NewName\")\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_api_failure_returns_none_without_raising(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+\n+        class _BrokenContactAPI:\n+            def get(self, _request):\n+                raise RuntimeError(\"API down\")\n+\n+        adapter._client = SimpleNamespace(\n+            contact=SimpleNamespace(v3=SimpleNamespace(user=_BrokenContactAPI()))\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_broken\"))\n+\n+        self.assertIsNone(result)"]},{"file":"tests/gateway/test_unauthorized_dm_behavior.py","hunks":["@@ -19,7 +19,7 @@ def _clear_auth_env(monkeypatch) -> None:\n         \"SMS_ALLOWED_USERS\",\n         \"MATTERMOST_ALLOWED_USERS\",\n         \"MATRIX_ALLOWED_USERS\",\n-        \"DINGTALK_ALLOWED_USERS\",\n+        \"DINGTALK_ALLOWED_USERS\", \"FEISHU_ALLOWED_USERS\",\n         \"GATEWAY_ALLOWED_USERS\",\n         \"TELEGRAM_ALLOW_ALL_USERS\",\n         \"DISCORD_ALLOW_ALL_USERS\",","@@ -30,7 +30,7 @@ def _clear_auth_env(monkeypatch) -> None:\n         \"SMS_ALLOW_ALL_USERS\",\n         \"MATTERMOST_ALLOW_ALL_USERS\",\n         \"MATRIX_ALLOW_ALL_USERS\",\n-        \"DINGTALK_ALLOW_ALL_USERS\",\n+        \"DINGTALK_ALLOW_ALL_USERS\", \"FEISHU_ALLOW_ALL_USERS\",\n         \"GATEWAY_ALLOW_ALL_USERS\",\n     ):\n         monkeypatch.delenv(key, raising=False)"]},{"file":"tools/cronjob_tools.py","hunks":["@@ -372,7 +372,7 @@ def remove_cronjob(job_id: str, task_id: str = None) -> str:\n             },\n             \"deliver\": {\n                 \"type\": \"string\",\n-                \"description\": \"Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'\"\n+                \"description\": \"Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, feishu, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'\"\n             },\n             \"model\": {\n                 \"type\": \"string\","]},{"file":"tools/send_message_tool.py","hunks":["@@ -15,6 +15,7 @@\n logger = logging.getLogger(__name__)\n \n _TELEGRAM_TOPIC_TARGET_RE = re.compile(r\"^\\s*(-?\\d+)(?::(\\d+))?\\s*$\")\n+_FEISHU_TARGET_RE = re.compile(r\"^\\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::([-A-Za-z0-9_]+))?\\s*$\")\n _IMAGE_EXTS = {\".jpg\", \".jpeg\", \".png\", \".webp\", \".gif\"}\n _VIDEO_EXTS = {\".mp4\", \".mov\", \".avi\", \".mkv\", \".3gp\"}\n _AUDIO_EXTS = {\".ogg\", \".opus\", \".mp3\", \".wav\", \".m4a\"}","@@ -128,6 +129,7 @@ def _handle_send(args):\n         \"mattermost\": Platform.MATTERMOST,\n         \"homeassistant\": Platform.HOMEASSISTANT,\n         \"dingtalk\": Platform.DINGTALK,\n+        \"feishu\": Platform.FEISHU,\n         \"email\": Platform.EMAIL,\n         \"sms\": Platform.SMS,\n     }","@@ -198,6 +200,10 @@ def _parse_target_ref(platform_name: str, target_ref: str):\n         match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref)\n         if match:\n             return match.group(1), match.group(2), True\n+    if platform_name == \"feishu\":\n+        match = _FEISHU_TARGET_RE.fullmatch(target_ref)\n+        if match:\n+            return match.group(1), match.group(2), True\n     if target_ref.lstrip(\"-\").isdigit():\n         return target_ref, None, True\n     return None, None, False","@@ -280,6 +286,13 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,\n     from gateway.platforms.discord import DiscordAdapter\n     from gateway.platforms.slack import SlackAdapter\n \n+    # Feishu adapter import is optional (requires lark-oapi)\n+    try:\n+        from gateway.platforms.feishu import FeishuAdapter\n+        _feishu_available = True\n+    except ImportError:\n+        _feishu_available = False\n+\n     media_files = media_files or []\n \n     # Platform message length limits (from adapter class attributes)","@@ -288,6 +301,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,\n         Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,\n         Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,\n     }\n+    if _feishu_available:\n+        _MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH\n \n     # Smart-chunk the message to fit within platform limits.\n     # For short messages or platforms without a known limit this is a no-op.","@@ -351,6 +366,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,\n             result = await _send_homeassistant(pconfig.token, pconfig.extra, chat_id, chunk)\n         elif platform == Platform.DINGTALK:\n             result = await _send_dingtalk(pconfig.extra, chat_id, chunk)\n+        elif platform == Platform.FEISHU:\n+            result = await _send_feishu(pconfig, chat_id, chunk, thread_id=thread_id)\n         else:\n             result = {\"error\": f\"Direct sending not yet implemented for {platform.value}\"}\n ","@@ -777,6 +794,63 @@ async def _send_dingtalk(extra, chat_id, message):\n         return {\"error\": f\"DingTalk send failed: {e}\"}\n \n \n+async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None):\n+    \"\"\"Send via Feishu/Lark using the adapter's send pipeline.\"\"\"\n+    try:\n+        from gateway.platforms.feishu import FeishuAdapter, FEISHU_AVAILABLE\n+        if not FEISHU_AVAILABLE:\n+            return {\"error\": \"Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'\"}\n+        from gateway.platforms.feishu import FEISHU_DOMAIN, LARK_DOMAIN\n+    except ImportError:\n+        return {\"error\": \"Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'\"}\n+\n+    media_files = media_files or []\n+\n+    try:\n+        adapter = FeishuAdapter(pconfig)\n+        domain_name = getattr(adapter, \"_domain_name\", \"feishu\")\n+        domain = FEISHU_DOMAIN if domain_name != \"lark\" else LARK_DOMAIN\n+        adapter._client = adapter._build_lark_client(domain)\n+        metadata = {\"thread_id\": thread_id} if thread_id else None\n+\n+        last_result = None\n+        if message.strip():\n+            last_result = await adapter.send(chat_id, message, metadata=metadata)\n+            if not last_result.success:\n+                return {\"error\": f\"Feishu send failed: {last_result.error}\"}\n+\n+        for media_path, is_voice in media_files:\n+            if not os.path.exists(media_path):\n+                return {\"error\": f\"Media file not found: {media_path}\"}\n+\n+            ext = os.path.splitext(media_path)[1].lower()\n+            if ext in _IMAGE_EXTS:\n+                last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)\n+            elif ext in _VIDEO_EXTS:\n+                last_result = await adapter.send_video(chat_id, media_path, metadata=metadata)\n+            elif ext in _VOICE_EXTS and is_voice:\n+                last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)\n+            elif ext in _AUDIO_EXTS:\n+                last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)\n+            else:\n+                last_result = await adapter.send_document(chat_id, media_path, metadata=metadata)\n+\n+            if not last_result.success:\n+                return {\"error\": f\"Feishu media send failed: {last_result.error}\"}\n+\n+        if last_result is None:\n+            return {\"error\": \"No deliverable text or media remained after processing MEDIA tags\"}\n+\n+        return {\n+            \"success\": True,\n+            \"platform\": \"feishu\",\n+            \"chat_id\": chat_id,\n+            \"message_id\": last_result.message_id,\n+        }\n+    except Exception as e:\n+        return {\"error\": f\"Feishu send failed: {e}\"}\n+\n+\n def _check_send_message():\n     \"\"\"Gate send_message on gateway running (always available on messaging platforms).\"\"\"\n     platform = os.getenv(\"HERMES_SESSION_PLATFORM\", \"\")"]},{"file":"toolsets.py","hunks":["@@ -351,6 +351,12 @@\n         \"includes\": []\n     },\n \n+    \"hermes-feishu\": {\n+        \"description\": \"Feishu/Lark bot toolset - enterprise messaging via Feishu/Lark (full access)\",\n+        \"tools\": _HERMES_CORE_TOOLS,\n+        \"includes\": []\n+    },\n+\n     \"hermes-sms\": {\n         \"description\": \"SMS bot toolset - interact with Hermes via SMS (Twilio)\",\n         \"tools\": _HERMES_CORE_TOOLS,","@@ -360,7 +366,7 @@\n     \"hermes-gateway\": {\n         \"description\": \"Gateway toolset - union of all messaging platform tools\",\n         \"tools\": [],\n-        \"includes\": [\"hermes-telegram\", \"hermes-discord\", \"hermes-whatsapp\", \"hermes-slack\", \"hermes-signal\", \"hermes-homeassistant\", \"hermes-email\", \"hermes-sms\", \"hermes-mattermost\", \"hermes-matrix\", \"hermes-dingtalk\"]\n+        \"includes\": [\"hermes-telegram\", \"hermes-discord\", \"hermes-whatsapp\", \"hermes-slack\", \"hermes-signal\", \"hermes-homeassistant\", \"hermes-email\", \"hermes-sms\", \"hermes-mattermost\", \"hermes-matrix\", \"hermes-dingtalk\", \"hermes-feishu\"]\n     }\n }\n "]},{"file":"website/docs/reference/toolsets-reference.md","hunks":["@@ -21,6 +21,7 @@ Toolsets are named bundles of tools that you can enable with `hermes chat --tool\n | `hermes-cli` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_console`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `cronjob`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |\n | `hermes-api-server` | platform | _(same as hermes-cli)_ |\n | `hermes-dingtalk` | platform | _(same as hermes-cli)_ |\n+| `hermes-feishu` | platform | _(same as hermes-cli)_ |\n | `hermes-discord` | platform | _(same as hermes-cli)_ |\n | `hermes-email` | platform | _(same as hermes-cli)_ |\n | `hermes-gateway` | composite | Union of all messaging platform toolsets |"]},{"file":"website/docs/user-guide/messaging/feishu.md","hunks":["@@ -0,0 +1,129 @@\n+---\n+sidebar_position: 11\n+title: \"Feishu / Lark\"\n+description: \"Set up Hermes Agent as a Feishu or Lark bot\"\n+---\n+\n+# Feishu / Lark Setup\n+\n+Hermes Agent integrates with Feishu and Lark as a full-featured bot. Once connected, you can chat with the agent in direct messages or group chats, receive cron job results in a home chat, and send text, images, audio, and file attachments through the normal gateway flow.\n+\n+The integration supports both connection modes:\n+\n+- `websocket` — recommended; Hermes opens the outbound connection and you do not need a public webhook endpoint\n+- `webhook` — useful when you want Feishu/Lark to push events into your gateway over HTTP\n+\n+## How Hermes Behaves\n+\n+| Context | Behavior |\n+|---------|----------|\n+| Direct messages | Hermes responds to every message. |\n+| Group chats | Hermes responds when the bot is addressed in the chat. |\n+| Shared group chats | By default, session history is isolated per user inside a shared chat. |\n+\n+This shared-chat behavior is controlled by `config.yaml`:\n+\n+```yaml\n+group_sessions_per_user: true\n+```\n+\n+Set it to `false` only if you explicitly want one shared conversation per chat.\n+\n+## Step 1: Create a Feishu / Lark App\n+\n+1. Open the Feishu or Lark developer console:\n+   - Feishu: <https://open.feishu.cn/>\n+   - Lark: <https://open.larksuite.com/>\n+2. Create a new app.\n+3. In **Credentials & Basic Info**, copy the **App ID** and **App Secret**.\n+4. Enable the **Bot** capability for the app.\n+\n+:::warning\n+Keep the App Secret private. Anyone with it can impersonate your app.\n+:::\n+\n+## Step 2: Choose a Connection Mode\n+\n+### Recommended: WebSocket mode\n+\n+Use WebSocket mode when Hermes runs on your laptop, workstation, or a private server. No public URL is required.\n+\n+```bash\n+FEISHU_CONNECTION_MODE=websocket\n+```\n+\n+### Optional: Webhook mode\n+\n+Use webhook mode only when you already run Hermes behind a reachable HTTP endpoint.\n+\n+```bash\n+FEISHU_CONNECTION_MODE=webhook\n+```\n+\n+In webhook mode, Hermes serves a Feishu endpoint at:\n+\n+```text\n+/feishu/webhook\n+```\n+\n+## Step 3: Configure Hermes\n+\n+### Option A: Interactive Setup\n+\n+```bash\n+hermes gateway setup\n+```\n+\n+Select **Feishu / Lark** and fill in the prompts.\n+\n+### Option B: Manual Configuration\n+\n+Add the following to `~/.hermes/.env`:\n+\n+```bash\n+FEISHU_APP_ID=cli_xxx\n+FEISHU_APP_SECRET=[redacted:secret-assignment]\n+FEISHU_DOMAIN=feishu\n+FEISHU_CONNECTION_MODE=websocket\n+\n+# Optional but strongly recommended\n+FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy\n+FEISHU_HOME_CHANNEL=oc_xxx\n+```\n+\n+`FEISHU_DOMAIN` accepts:\n+\n+- `feishu` for Feishu China\n+- `lark` for Lark international\n+\n+## Step 4: Start the Gateway\n+\n+```bash\n+hermes gateway\n+```\n+\n+Then message the bot from Feishu/Lark to confirm that the connection is live.\n+\n+## Home Chat\n+\n+Use `/set-home` in a Feishu/Lark chat to mark it as the home channel for cron job results and cross-platform notifications.\n+\n+You can also preconfigure it:\n+\n+```bash\n+FEISHU_HOME_CHANNEL=oc_xxx\n+```\n+\n+## Security\n+\n+For production use, set an allowlist:\n+\n+```bash\n+FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy\n+```\n+\n+If you leave the allowlist empty, anyone who can reach the bot may be able to use it.\n+\n+## Toolset\n+\n+Feishu / Lark uses the `hermes-feishu` platform preset, which includes the same core tools as Telegram and other gateway-based messaging platforms."]},{"file":"website/docs/user-guide/messaging/index.md","hunks":["@@ -6,7 +6,7 @@ description: \"Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal,\n \n # Messaging Gateway\n \n-Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.\n+Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.\n \n For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes).\n ","@@ -27,6 +27,7 @@ flowchart TB\n             mm[Mattermost]\n             mx[Matrix]\n             dt[DingTalk]\n+    fs[Feishu/Lark]\n             api[\"API Server<br/>(OpenAI-compatible)\"]\n             wh[Webhooks]\n         end","@@ -328,6 +329,7 @@ Each platform has its own toolset:\n | Mattermost | `hermes-mattermost` | Full tools including terminal |\n | Matrix | `hermes-matrix` | Full tools including terminal |\n | DingTalk | `hermes-dingtalk` | Full tools including terminal |\n+| Feishu/Lark | `hermes-feishu` | Full tools including terminal |\n | API Server | `hermes` (default) | Full tools including terminal |\n | Webhooks | `hermes-webhook` | Full tools including terminal |\n ","@@ -344,5 +346,6 @@ Each platform has its own toolset:\n - [Mattermost Setup](mattermost.md)\n - [Matrix Setup](matrix.md)\n - [DingTalk Setup](dingtalk.md)\n+- [Feishu/Lark Setup](feishu.md)\n - [Open WebUI + API Server](open-webui.md)\n - [Webhooks](webhooks.md)"]},{"file":"website/sidebars.ts","hunks":["@@ -54,6 +54,7 @@ const sidebars: SidebarsConfig = {\n             'user-guide/messaging/mattermost',\n             'user-guide/messaging/matrix',\n             'user-guide/messaging/dingtalk',\n+              'user-guide/messaging/feishu',\n             'user-guide/messaging/open-webui',\n             'user-guide/messaging/webhooks',\n           ],\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}