diff --git a/gateway/config.py b/gateway/config.py
index bc077b1994ea8..3df8667421c1f 100644
--- a/gateway/config.py
+++ b/gateway/config.py
@@ -74,6 +74,21 @@ def _normalize_notice_delivery(value: Any, default: str = "public") -> str:
return default
+def _normalize_group_model_defaults(raw: Any) -> Dict[str, str]:
+ """Coerce group_model_defaults to ``Dict[str, str]`` from config.
+
+ Strips falsy keys/values and ensures string-typed entries so downstream
+ code (gateway/run.py) can simply call ``.get(chat_id)`` without type
+ gymnastics.
+
+ / 将 config.yaml 中的 group_model_defaults 转为干净的 ``{str: str}`` 字典,
+ / 过滤空值并确保字符串类型,下游直接 ``.get(chat_id)`` 即可。
+ """
+ if not isinstance(raw, dict):
+ return {}
+ return {str(k): str(v) for k, v in raw.items() if k and v}
+
+
def _ensure_platform_extra_dict(platforms_data: dict, name: str) -> tuple[dict, dict]:
"""Get-or-create ``platforms_data[name]`` and its nested ``extra`` dict.
@@ -493,6 +508,28 @@ class GatewayConfig:
# fresh session exactly as if the reset policy had fired. 0 = disabled.
session_store_max_age_days: int = 90
+ # Per-group default model overrides: {chat_id: model_name}
+ #
+ # When a message arrives from a group chat, the gateway checks this map
+ # before falling through to the global ``model.default``. The per-session
+ # ``/model`` command still takes precedence over this (see run.py).
+ #
+ # This gives operators a three-tier model resolution:
+ # global default < group default < per-session /model override
+ #
+ # Example config.yaml entry:
+ # group_model_defaults:
+ # oc_869c1d931f317119d8f51f20797b599c: deepseek-v4-flash # 课程设计部
+ # oc_53d22ce34646cebabf06e48ff986ee4c: mistral-large # 自媒体部
+ #
+ # / 按群(chat_id)设置默认模型。消息来自某个群聊时,网关先查这个映射,
+ # / 再落回全局 model.default。手动 /model 覆盖优先级更高。
+ # / 由此实现三层模型解析:全局默认 < 群默认 < 会话覆盖。
+ #
+ # Bilingual note: field names and log messages stay in English for
+ # upstream compatibility; operator-facing comments are kept bilingual.
+ group_model_defaults: Dict[str, str] = field(default_factory=dict)
+
def get_connected_platforms(self) -> List[Platform]:
"""Return list of platforms that are enabled and configured."""
connected = []
@@ -654,6 +691,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
unauthorized_dm_behavior=unauthorized_dm_behavior,
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
session_store_max_age_days=session_store_max_age_days,
+ group_model_defaults=_normalize_group_model_defaults(
+ data.get("group_model_defaults")
+ ),
)
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
@@ -761,6 +801,14 @@ def load_gateway_config() -> GatewayConfig:
"pair",
)
+ # Pass-through: group_model_defaults is a flat {chat_id: model}
+ # map defined at the top level of config.yaml. It is consumed
+ # at runtime by _resolve_session_agent_runtime in run.py.
+ # / 透传 group_model_defaults,不做任何转换。
+ # / 运行时由 run.py 的 _resolve_session_agent_runtime 消费。
+ if "group_model_defaults" in yaml_cfg:
+ gw_data["group_model_defaults"] = yaml_cfg["group_model_defaults"]
+
# Merge platforms section from config.yaml into gw_data so that
# nested keys like platforms.webhook.extra.routes are loaded.
yaml_platforms = yaml_cfg.get("platforms")
diff --git a/gateway/run.py b/gateway/run.py
index 2e559d311f231..de063433b3100 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -2256,6 +2256,35 @@ def _resolve_session_agent_runtime(
resolved_session_key = None
model = _resolve_gateway_model(user_config)
+
+ # ── Per-group default model ──────────────────────────────────────
+ #
+ # If the incoming message is from a group chat, check
+ # group_model_defaults for a chat_id → model mapping BEFORE
+ # consulting per-session overrides. This gives operators a
+ # three-tier resolution chain:
+ #
+ # 1. Global default (config.yaml → model.default)
+ # 2. Group default (config.yaml → group_model_defaults[chat_id])
+ # 3. Session override (/model command in a specific topic/thread)
+ #
+ # Step 3 (session override) is evaluated immediately below, so this
+ # value is a lower-priority base that gets replaced if an override
+ # exists.
+ #
+ # / 群默认模型检查。消息来自群聊时,在查会话覆盖之前先查群默认。
+ # / 优先级:全局默认 < 群默认 < 会话覆盖。
+ # / 下面紧跟着会话覆盖逻辑,所以这里的值会被覆盖掉(如果有覆盖的话)。
+ if source is not None and source.chat_id:
+ group_defaults = getattr(self.config, "group_model_defaults", None) or {}
+ group_model = group_defaults.get(str(source.chat_id))
+ if group_model:
+ logger.info(
+ "Group model default: chat_id=%s model=%s (config was %s)",
+ source.chat_id, group_model, model,
+ )
+ model = group_model
+
override = self._session_model_overrides.get(resolved_session_key) if resolved_session_key else None
if override:
override_model = override.get("model", model)
diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py
new file mode 100644
index 0000000000000..d59ef0fb10603
--- /dev/null
+++ b/plugins/platforms/feishu/adapter.py
@@ -0,0 +1,5662 @@
+"""
+Feishu/Lark platform adapter.
+
+Supports:
+- WebSocket long connection and Webhook transport
+- Direct-message and group @mention-gated text receive/send
+- Inbound image/file/audio/media caching
+- Gateway allowlist integration via FEISHU_ALLOWED_USERS
+- Persistent dedup state across restarts
+- Per-chat serial message processing (matches openclaw createChatQueue)
+- Processing status reactions: Typing while working, removed on success,
+ swapped for CrossMark on failure
+- Reaction events routed as synthetic text events (matches openclaw)
+- Interactive card button-click events routed as synthetic COMMAND events
+- Webhook anomaly tracking (matches openclaw createWebhookAnomalyTracker)
+- Verification token validation as second auth layer (matches openclaw)
+
+Feishu identity model
+---------------------
+Feishu uses three user-ID tiers (official docs:
+https://open.feishu.cn/document/home/user-identity-introduction/introduction):
+
+ open_id (ou_xxx) — **App-scoped**. The same person gets a different
+ open_id under each Feishu app. Always available in
+ event payloads without extra permissions.
+ user_id (u_xxx) — **Tenant-scoped**. Stable within a company but
+ requires the ``contact:user.employee_id:readonly``
+ scope. May not be present.
+ union_id (on_xxx) — **Developer-scoped**. Same across all apps owned by
+ one developer/ISV. Best cross-app stable ID.
+
+For bots specifically:
+
+ app_id — The application's canonical credential identifier.
+ bot open_id — Returned by ``/bot/v3/info``. This is the bot's own
+ open_id *within its app context* and is what Feishu
+ puts in ``mentions[].id.open_id`` when someone
+ @-mentions the bot. Used for mention gating only.
+
+In single-bot mode (what Hermes currently supports), open_id works as a
+de-facto unique user identifier since there is only one app context.
+
+Session-key participant isolation prefers ``union_id`` (via user_id_alt)
+over ``open_id`` (via user_id) so that sessions stay stable if the same
+user is seen through different apps in the future.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import collections
+import concurrent.futures
+import hashlib
+import hmac
+import itertools
+import json
+import logging
+import mimetypes
+import os
+import re
+import threading
+import time
+import uuid
+from collections import OrderedDict
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any, Dict, List, Literal, Optional, Sequence
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlencode
+from urllib.request import Request, urlopen
+
+# aiohttp/websockets are independent optional deps — import outside lark_oapi
+# so they remain available for tests and webhook mode even if lark_oapi is missing.
+try:
+ import aiohttp
+ from aiohttp import web
+except ImportError:
+ aiohttp = None # type: ignore[assignment]
+ web = None # type: ignore[assignment]
+
+try:
+ import websockets
+except ImportError:
+ websockets = None # type: ignore[assignment]
+
+try:
+ import lark_oapi as lark
+ from lark_oapi.api.application.v6 import GetApplicationRequest
+ from lark_oapi.api.im.v1 import (
+ CreateFileRequest,
+ CreateFileRequestBody,
+ CreateImageRequest,
+ CreateImageRequestBody,
+ CreateMessageRequest,
+ CreateMessageRequestBody,
+ GetChatRequest,
+ GetMessageRequest,
+ GetMessageResourceRequest,
+ P2ImMessageMessageReadV1,
+ ReplyMessageRequest,
+ ReplyMessageRequestBody,
+ UpdateMessageRequest,
+ UpdateMessageRequestBody,
+ )
+ from lark_oapi.core import AccessTokenType, HttpMethod
+ from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
+ from lark_oapi.core.model import BaseRequest
+ from lark_oapi.event.callback.model.p2_card_action_trigger import (
+ CallBackCard,
+ P2CardActionTriggerResponse,
+ )
+ from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
+ from lark_oapi.ws import Client as FeishuWSClient
+
+ FEISHU_AVAILABLE = True
+except ImportError:
+ FEISHU_AVAILABLE = False
+ lark = None # type: ignore[assignment]
+ CallBackCard = None # type: ignore[assignment]
+ P2CardActionTriggerResponse = None # type: ignore[assignment]
+ EventDispatcherHandler = None # type: ignore[assignment]
+ FeishuWSClient = None # type: ignore[assignment]
+ FEISHU_DOMAIN = None # type: ignore[assignment]
+ LARK_DOMAIN = None # type: ignore[assignment]
+
+FEISHU_WEBSOCKET_AVAILABLE = websockets is not None
+FEISHU_WEBHOOK_AVAILABLE = aiohttp is not None
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import (
+ BasePlatformAdapter,
+ MessageEvent,
+ MessageType,
+ ProcessingOutcome,
+ SendResult,
+ SUPPORTED_DOCUMENT_TYPES,
+ cache_document_from_bytes,
+ cache_image_from_url,
+ cache_audio_from_bytes,
+ cache_image_from_bytes,
+)
+from gateway.status import acquire_scoped_lock, release_scoped_lock
+from hermes_constants import get_hermes_home
+from utils import atomic_json_write, env_float, env_int
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Regex patterns
+# ---------------------------------------------------------------------------
+
+_MARKDOWN_HINT_RE = re.compile(
+ r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(.+?)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^\|.*\|)|(^>\s)",
+ re.MULTILINE,
+)
+# Detect markdown tables: a line starting with | followed by a separator line.
+# Feishu post-type 'md' elements do not render tables, so we force text mode.
+_MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", re.MULTILINE)
+_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
+_MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$")
+_MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^```\s*$")
+_MENTION_RE = re.compile(r"@_user_\d+")
+_MULTISPACE_RE = re.compile(r"[ \t]{2,}")
+_POST_CONTENT_INVALID_RE = re.compile(r"content format of the post type is incorrect", re.IGNORECASE)
+# ---------------------------------------------------------------------------
+# Media type sets and upload constants
+# ---------------------------------------------------------------------------
+
+_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
+_AUDIO_EXTENSIONS = {".ogg", ".mp3", ".wav", ".m4a", ".aac", ".flac", ".opus", ".webm"}
+_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".3gp"}
+_DOCUMENT_MIME_TO_EXT = {mime: ext for ext, mime in SUPPORTED_DOCUMENT_TYPES.items()}
+_FEISHU_IMAGE_UPLOAD_TYPE = "message"
+_FEISHU_FILE_UPLOAD_TYPE = "stream"
+_FEISHU_OPUS_UPLOAD_EXTENSIONS = {".ogg", ".opus"}
+_FEISHU_MEDIA_UPLOAD_EXTENSIONS = {".mp4", ".mov", ".avi", ".m4v"}
+_FEISHU_DOC_UPLOAD_TYPES = {
+ ".pdf": "pdf",
+ ".doc": "doc",
+ ".docx": "doc",
+ ".xls": "xls",
+ ".xlsx": "xls",
+ ".ppt": "ppt",
+ ".pptx": "ppt",
+}
+# ---------------------------------------------------------------------------
+# Connection, retry and batching tuning
+# ---------------------------------------------------------------------------
+
+_MAX_TEXT_INJECT_BYTES = 100 * 1024
+_FEISHU_CONNECT_ATTEMPTS = 3
+_FEISHU_SEND_ATTEMPTS = 3
+_FEISHU_APP_LOCK_SCOPE = "feishu-app-id"
+_DEFAULT_TEXT_BATCH_DELAY_SECONDS = 0.6
+_DEFAULT_TEXT_BATCH_MAX_MESSAGES = 8
+_DEFAULT_TEXT_BATCH_MAX_CHARS = 4000
+_DEFAULT_MEDIA_BATCH_DELAY_SECONDS = 0.8
+_DEFAULT_DEDUP_CACHE_SIZE = 2048
+_DEFAULT_WEBHOOK_HOST = "127.0.0.1"
+_DEFAULT_WEBHOOK_PORT = 8765
+_DEFAULT_WEBHOOK_PATH = "/feishu/webhook"
+# ---------------------------------------------------------------------------
+# TTL, rate-limit and webhook security constants
+# ---------------------------------------------------------------------------
+
+_FEISHU_DEDUP_TTL_SECONDS = 24 * 60 * 60 # 24 hours — matches openclaw
+_FEISHU_SENDER_NAME_TTL_SECONDS = 10 * 60 # 10 minutes sender-name cache
+_FEISHU_WEBHOOK_MAX_BODY_BYTES = 1 * 1024 * 1024 # 1 MB body limit
+_FEISHU_WEBHOOK_RATE_WINDOW_SECONDS = 60 # sliding window for rate limiter
+_FEISHU_WEBHOOK_RATE_LIMIT_MAX = 120 # max requests per window per IP — matches openclaw
+_FEISHU_WEBHOOK_RATE_MAX_KEYS = 4096 # max tracked keys (prevents unbounded growth)
+_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS = 30 # max seconds to read request body
+_FEISHU_WEBHOOK_ANOMALY_THRESHOLD = 25 # consecutive error responses before WARNING log
+_FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60 # anomaly tracker TTL (6 hours) — matches openclaw
+_FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS = 15 * 60 # card action token dedup window (15 min)
+
+_APPROVAL_CHOICE_MAP: Dict[str, str] = {
+ "approve_once": "once",
+ "approve_session": "session",
+ "approve_always": "always",
+ "deny": "deny",
+}
+_APPROVAL_LABEL_MAP: Dict[str, str] = {
+ "once": "Approved once",
+ "session": "Approved for session",
+ "always": "Approved permanently",
+ "deny": "Denied",
+}
+
+
+async def _read_limited_feishu_webhook_body(request: Any, max_bytes: int) -> bytes:
+ """Read at most ``max_bytes`` from an aiohttp request body."""
+ try:
+ body = await request.content.readexactly(max_bytes + 1)
+ except asyncio.IncompleteReadError as exc:
+ body = exc.partial
+ if len(body) > max_bytes:
+ raise ValueError("payload too large")
+ return body
+
+
+_FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs
+_FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback
+
+# Feishu reactions render as prominent badges, unlike Discord/Telegram's
+# small footer emoji — a success badge on every message would add noise, so
+# we only mark start (Typing) and failure (CrossMark); the reply itself is
+# the success signal.
+_FEISHU_REACTION_IN_PROGRESS = "Typing"
+_FEISHU_REACTION_FAILURE = "CrossMark"
+# Bound on the (message_id → reaction_id) handle cache. Happy-path entries
+# drain on completion; the cap is a safeguard against unbounded growth from
+# delete-failures, not a capacity plan.
+_FEISHU_PROCESSING_REACTION_CACHE_SIZE = 1024
+_FEISHU_MESSAGE_TEXT_CACHE_SIZE = 512 # LRU cap for reply-context message text lookups
+
+# QR onboarding constants
+_ONBOARD_ACCOUNTS_URLS = {
+ "feishu": "https://accounts.feishu.cn",
+ "lark": "https://accounts.larksuite.com",
+}
+_ONBOARD_OPEN_URLS = {
+ "feishu": "https://open.feishu.cn",
+ "lark": "https://open.larksuite.com",
+}
+_REGISTRATION_PATH = "/oauth/v1/app/registration"
+_ONBOARD_REQUEST_TIMEOUT_S = 10
+
+# ---------------------------------------------------------------------------
+# Fallback display strings
+# ---------------------------------------------------------------------------
+
+FALLBACK_POST_TEXT = "[Rich text message]"
+FALLBACK_FORWARD_TEXT = "[Merged forward message]"
+FALLBACK_SHARE_CHAT_TEXT = "[Shared chat]"
+FALLBACK_INTERACTIVE_TEXT = "[Interactive message]"
+FALLBACK_IMAGE_TEXT = "[Image]"
+FALLBACK_ATTACHMENT_TEXT = "[Attachment]"
+# ---------------------------------------------------------------------------
+# Post/card parsing helpers
+# ---------------------------------------------------------------------------
+
+_PREFERRED_LOCALES = ("zh_cn", "en_us")
+_MARKDOWN_SPECIAL_CHARS_RE = re.compile(r"([\\`*_{}\[\]()#+\-!|>~])")
+_MENTION_PLACEHOLDER_RE = re.compile(r"@_user_\d+")
+_MENTION_BOUNDARY_CHARS = frozenset(" \t\n\r.,;:!?、,。;:!?()[]{}<>\"'`")
+_TRAILING_TERMINAL_PUNCT = frozenset(" \t\n\r.!?。!?")
+_WHITESPACE_RE = re.compile(r"\s+")
+_SUPPORTED_CARD_TEXT_KEYS = (
+ "title",
+ "text",
+ "content",
+ "label",
+ "value",
+ "name",
+ "summary",
+ "subtitle",
+ "description",
+ "placeholder",
+ "hint",
+)
+_SKIP_TEXT_KEYS = {
+ "tag",
+ "type",
+ "msg_type",
+ "message_type",
+ "chat_id",
+ "open_chat_id",
+ "share_chat_id",
+ "file_key",
+ "image_key",
+ "user_id",
+ "open_id",
+ "union_id",
+ "url",
+ "href",
+ "link",
+ "token",
+ "template",
+ "locale",
+}
+
+
+@dataclass(frozen=True)
+class FeishuPostMediaRef:
+ file_key: str
+ file_name: str = ""
+ resource_type: str = "file"
+
+
+@dataclass(frozen=True)
+class FeishuMentionRef:
+ name: str = ""
+ open_id: str = ""
+ is_all: bool = False
+ is_self: bool = False
+
+
+@dataclass(frozen=True)
+class _FeishuBotIdentity:
+ open_id: str = ""
+ user_id: str = ""
+ name: str = ""
+
+ def matches(self, *, open_id: str, user_id: str, name: str) -> bool:
+ # Precedence: open_id > user_id > name. IDs are authoritative when both
+ # sides have them; the next tier is only considered when either side
+ # lacks the current one.
+ if open_id and self.open_id:
+ return open_id == self.open_id
+ if user_id and self.user_id:
+ return user_id == self.user_id
+ return bool(self.name) and name == self.name
+
+
+@dataclass(frozen=True)
+class FeishuPostParseResult:
+ text_content: str
+ image_keys: List[str] = field(default_factory=list)
+ media_refs: List[FeishuPostMediaRef] = field(default_factory=list)
+
+
+@dataclass(frozen=True)
+class FeishuNormalizedMessage:
+ raw_type: str
+ text_content: str
+ preferred_message_type: str = "text"
+ image_keys: List[str] = field(default_factory=list)
+ media_refs: List[FeishuPostMediaRef] = field(default_factory=list)
+ mentions: List[FeishuMentionRef] = field(default_factory=list)
+ relation_kind: str = "plain"
+ metadata: Dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class FeishuAdapterSettings:
+ app_id: str # Canonical bot/app identifier (credential, not from event payloads)
+ app_secret: str
+ domain_name: str
+ connection_mode: str
+ encrypt_key: str
+ verification_token: str
+ group_policy: str
+ allowed_group_users: frozenset[str]
+ # Bot's own open_id (app-scoped) — returned by /bot/v3/info. Used only for
+ # @mention matching: Feishu puts this value in mentions[].id.open_id when
+ # a user @-mentions the bot in a group chat.
+ bot_open_id: str
+ # Bot's user_id (tenant-scoped) — optional, used as fallback mention match.
+ bot_user_id: str
+ bot_name: str
+ dedup_cache_size: int
+ text_batch_delay_seconds: float
+ text_batch_split_delay_seconds: float
+ text_batch_max_messages: int
+ text_batch_max_chars: int
+ media_batch_delay_seconds: float
+ webhook_host: str
+ webhook_port: int
+ webhook_path: str
+ ws_reconnect_nonce: int = 30
+ ws_reconnect_interval: int = 120
+ ws_ping_interval: Optional[int] = None
+ ws_ping_timeout: Optional[int] = None
+ admins: frozenset[str] = frozenset()
+ default_group_policy: str = ""
+ group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict)
+ allow_bots: str = "none" # "none" | "mentions" | "all"
+ require_mention: bool = True
+
+
+@dataclass
+class FeishuGroupRule:
+ """Per-group policy rule for controlling which users may interact with the bot."""
+
+ policy: str # "open" | "allowlist" | "blacklist" | "admin_only" | "disabled"
+ allowlist: set[str] = field(default_factory=set)
+ blacklist: set[str] = field(default_factory=set)
+ require_mention: Optional[bool] = None # None = inherit global
+
+
+@dataclass
+class FeishuBatchState:
+ events: Dict[str, MessageEvent] = field(default_factory=dict)
+ tasks: Dict[str, asyncio.Task] = field(default_factory=dict)
+ counts: Dict[str, int] = field(default_factory=dict)
+
+
+# ---------------------------------------------------------------------------
+# Admission: policy types
+# ---------------------------------------------------------------------------
+
+
+RejectReason = Literal[
+ "self_echo",
+ "self_ids_unknown",
+ "bots_disabled",
+ "bot_not_mentioned",
+ "group_policy_rejected",
+]
+
+
+def _is_bot_sender(sender: Any) -> bool:
+ # receive_v1 docs say {user, bot}; accept "app" defensively.
+ return getattr(sender, "sender_type", "") in {"bot", "app"}
+
+
+def _sender_identity(sender: Any) -> frozenset:
+ # Take any non-empty id variant — tenant sender_id_type decides which are populated.
+ sid = getattr(sender, "sender_id", None)
+ if sid is None:
+ return frozenset()
+ return frozenset(
+ v for v in (
+ getattr(sid, "open_id", None),
+ getattr(sid, "user_id", None),
+ getattr(sid, "union_id", None),
+ )
+ if v
+ )
+
+
+# ---------------------------------------------------------------------------
+# Markdown rendering helpers
+# ---------------------------------------------------------------------------
+
+
+def _escape_markdown_text(text: str) -> str:
+ return _MARKDOWN_SPECIAL_CHARS_RE.sub(r"\\\1", text)
+
+
+def _to_boolean(value: Any) -> bool:
+ return value is True or value == 1 or value == "true"
+
+
+def _is_style_enabled(style: Dict[str, Any] | None, key: str) -> bool:
+ if not style:
+ return False
+ return _to_boolean(style.get(key))
+
+
+def _wrap_inline_code(text: str) -> str:
+ max_run = max([0, *[len(run) for run in re.findall(r"`+", text)]])
+ fence = "`" * (max_run + 1)
+ body = f" {text} " if text.startswith("`") or text.endswith("`") else text
+ return f"{fence}{body}{fence}"
+
+
+def _sanitize_fence_language(language: str) -> str:
+ return language.strip().replace("\n", " ").replace("\r", " ")
+
+
+def _render_text_element(element: Dict[str, Any]) -> str:
+ text = str(element.get("text", "") or "")
+ style = element.get("style")
+ style_dict = style if isinstance(style, dict) else None
+
+ if _is_style_enabled(style_dict, "code"):
+ return _wrap_inline_code(text)
+
+ rendered = _escape_markdown_text(text)
+ if not rendered:
+ return ""
+ if _is_style_enabled(style_dict, "bold"):
+ rendered = f"**{rendered}**"
+ if _is_style_enabled(style_dict, "italic"):
+ rendered = f"*{rendered}*"
+ if _is_style_enabled(style_dict, "underline"):
+ rendered = f"{rendered}"
+ if _is_style_enabled(style_dict, "strikethrough"):
+ rendered = f"~~{rendered}~~"
+ return rendered
+
+
+def _render_code_block_element(element: Dict[str, Any]) -> str:
+ language = _sanitize_fence_language(
+ str(element.get("language", "") or "") or str(element.get("lang", "") or "")
+ )
+ code = (
+ str(element.get("text", "") or "") or str(element.get("content", "") or "")
+ ).replace("\r\n", "\n")
+ trailing_newline = "" if code.endswith("\n") else "\n"
+ return f"```{language}\n{code}{trailing_newline}```"
+
+
+def _strip_markdown_to_plain_text(text: str) -> str:
+ """Strip markdown formatting to plain text for Feishu text fallbacks.
+
+ Delegates common markdown stripping to the shared helper and adds
+ Feishu-specific patterns (blockquotes, strikethrough, underline tags,
+ horizontal rules, \\r\\n normalisation).
+ """
+ from gateway.platforms.helpers import strip_markdown
+ plain = text.replace("\r\n", "\n")
+ plain = _MARKDOWN_LINK_RE.sub(lambda m: f"{m.group(1)} ({m.group(2).strip()})", plain)
+ plain = re.sub(r"^>\s?", "", plain, flags=re.MULTILINE)
+ plain = re.sub(r"^\s*---+\s*$", "---", plain, flags=re.MULTILINE)
+ plain = re.sub(r"~~([^~\n]+)~~", r"\1", plain)
+ plain = re.sub(r"([\s\S]*?)", r"\1", plain)
+ plain = strip_markdown(plain)
+ return plain
+
+
+def _coerce_int(value: Any, default: Optional[int] = None, min_value: int = 0) -> Optional[int]:
+ """Coerce value to int with optional default and minimum constraint."""
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError):
+ return default
+ return parsed if parsed >= min_value else default
+
+
+def _coerce_required_int(value: Any, default: int, min_value: int = 0) -> int:
+ parsed = _coerce_int(value, default=default, min_value=min_value)
+ return default if parsed is None else parsed
+
+
+# ---------------------------------------------------------------------------
+# Post payload builders and parsers
+# ---------------------------------------------------------------------------
+
+
+def _build_markdown_post_payload(content: str) -> str:
+ rows = _build_markdown_post_rows(content)
+ return json.dumps(
+ {
+ "zh_cn": {
+ "content": rows,
+ }
+ },
+ ensure_ascii=False,
+ )
+
+
+def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]:
+ """Build Feishu post rows while isolating fenced code blocks.
+
+ Feishu's `md` renderer can swallow trailing content when a fenced code block
+ appears inside one large markdown element. Split the reply at real fence
+ lines so prose before/after the code block remains visible while code stays
+ in a dedicated row.
+ """
+ if not content:
+ return [[{"tag": "md", "text": ""}]]
+ if "```" not in content:
+ return [[{"tag": "md", "text": content}]]
+
+ rows: List[List[Dict[str, str]]] = []
+ current: List[str] = []
+ in_code_block = False
+
+ def _flush_current() -> None:
+ nonlocal current
+ if not current:
+ return
+ segment = "\n".join(current)
+ if segment.strip():
+ rows.append([{"tag": "md", "text": segment}])
+ current = []
+
+ for raw_line in content.splitlines():
+ stripped_line = raw_line.strip()
+ is_fence = bool(
+ _MARKDOWN_FENCE_CLOSE_RE.match(stripped_line)
+ if in_code_block
+ else _MARKDOWN_FENCE_OPEN_RE.match(stripped_line)
+ )
+
+ if is_fence:
+ if not in_code_block:
+ _flush_current()
+ current.append(raw_line)
+ in_code_block = not in_code_block
+ if not in_code_block:
+ _flush_current()
+ continue
+
+ current.append(raw_line)
+
+ _flush_current()
+ return rows or [[{"tag": "md", "text": content}]]
+
+
+def parse_feishu_post_payload(
+ payload: Any,
+ *,
+ mentions_map: Optional[Dict[str, FeishuMentionRef]] = None,
+) -> FeishuPostParseResult:
+ resolved = _resolve_post_payload(payload)
+ if not resolved:
+ return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT)
+
+ image_keys: List[str] = []
+ media_refs: List[FeishuPostMediaRef] = []
+ parts: List[str] = []
+
+ title = _normalize_feishu_text(str(resolved.get("title", "")).strip())
+ if title:
+ parts.append(title)
+
+ for row in resolved.get("content", []) or []:
+ if not isinstance(row, list):
+ continue
+ row_text = _normalize_feishu_text(
+ "".join(
+ _render_post_element(item, image_keys, media_refs, mentions_map)
+ for item in row
+ )
+ )
+ if row_text:
+ parts.append(row_text)
+
+ return FeishuPostParseResult(
+ text_content="\n".join(parts).strip() or FALLBACK_POST_TEXT,
+ image_keys=image_keys,
+ media_refs=media_refs,
+ )
+
+
+def _resolve_post_payload(payload: Any) -> Dict[str, Any]:
+ direct = _to_post_payload(payload)
+ if direct:
+ return direct
+ if not isinstance(payload, dict):
+ return {}
+
+ wrapped = payload.get("post")
+ wrapped_direct = _resolve_locale_payload(wrapped)
+ if wrapped_direct:
+ return wrapped_direct
+ return _resolve_locale_payload(payload)
+
+
+def _resolve_locale_payload(payload: Any) -> Dict[str, Any]:
+ direct = _to_post_payload(payload)
+ if direct:
+ return direct
+ if not isinstance(payload, dict):
+ return {}
+
+ for key in _PREFERRED_LOCALES:
+ candidate = _to_post_payload(payload.get(key))
+ if candidate:
+ return candidate
+ for value in payload.values():
+ candidate = _to_post_payload(value)
+ if candidate:
+ return candidate
+ return {}
+
+
+def _to_post_payload(candidate: Any) -> Dict[str, Any]:
+ if not isinstance(candidate, dict):
+ return {}
+ content = candidate.get("content")
+ if not isinstance(content, list):
+ return {}
+ return {
+ "title": str(candidate.get("title", "") or ""),
+ "content": content,
+ }
+
+
+def _render_post_element(
+ element: Any,
+ image_keys: List[str],
+ media_refs: List[FeishuPostMediaRef],
+ mentions_map: Optional[Dict[str, FeishuMentionRef]] = None,
+) -> str:
+ if isinstance(element, str):
+ return element
+ if not isinstance(element, dict):
+ return ""
+
+ tag = str(element.get("tag", "")).strip().lower()
+ if tag == "text":
+ return _render_text_element(element)
+ if tag == "a":
+ href = str(element.get("href", "")).strip()
+ label = str(element.get("text", href) or "").strip()
+ if not label:
+ return ""
+ escaped_label = _escape_markdown_text(label)
+ return f"[{escaped_label}]({href})" if href else escaped_label
+ if tag == "at":
+ # Post .user_id is a placeholder ("@_user_N" or "@_all"); look up
+ # the real ref in mentions_map for the display name.
+ placeholder = str(element.get("user_id", "")).strip()
+ if placeholder == "@_all":
+ # Feishu SDK sometimes omits @_all from the top-level mentions
+ # payload; record it here so the caller's mention list stays complete.
+ if mentions_map is not None and "@_all" not in mentions_map:
+ mentions_map["@_all"] = FeishuMentionRef(is_all=True)
+ return "@all"
+ ref = (mentions_map or {}).get(placeholder)
+ if ref is not None:
+ display_name = ref.name or ref.open_id or "user"
+ else:
+ display_name = str(element.get("user_name", "")).strip() or "user"
+ return f"@{_escape_markdown_text(display_name)}"
+ if tag in {"img", "image"}:
+ image_key = str(element.get("image_key", "")).strip()
+ if image_key and image_key not in image_keys:
+ image_keys.append(image_key)
+ alt = str(element.get("text", "")).strip() or str(element.get("alt", "")).strip()
+ return f"[Image: {alt}]" if alt else "[Image]"
+ if tag in {"media", "file", "audio", "video"}:
+ file_key = str(element.get("file_key", "")).strip()
+ file_name = (
+ str(element.get("file_name", "")).strip()
+ or str(element.get("title", "")).strip()
+ or str(element.get("text", "")).strip()
+ )
+ if file_key:
+ media_refs.append(
+ FeishuPostMediaRef(
+ file_key=file_key,
+ file_name=file_name,
+ resource_type=tag if tag in {"audio", "video"} else "file",
+ )
+ )
+ return f"[Attachment: {file_name}]" if file_name else "[Attachment]"
+ if tag in {"emotion", "emoji"}:
+ label = str(element.get("text", "")).strip() or str(element.get("emoji_type", "")).strip()
+ return f":{_escape_markdown_text(label)}:" if label else "[Emoji]"
+ if tag == "br":
+ return "\n"
+ if tag in {"hr", "divider"}:
+ return "\n\n---\n\n"
+ if tag == "code":
+ code = str(element.get("text", "") or "") or str(element.get("content", "") or "")
+ return _wrap_inline_code(code) if code else ""
+ if tag in {"code_block", "pre"}:
+ return _render_code_block_element(element)
+
+ nested_parts: List[str] = []
+ for key in ("text", "title", "content", "children", "elements"):
+ extracted = _render_nested_post(element.get(key), image_keys, media_refs, mentions_map)
+ if extracted:
+ nested_parts.append(extracted)
+ return " ".join(part for part in nested_parts if part)
+
+
+def _render_nested_post(
+ value: Any,
+ image_keys: List[str],
+ media_refs: List[FeishuPostMediaRef],
+ mentions_map: Optional[Dict[str, FeishuMentionRef]] = None,
+) -> str:
+ if isinstance(value, str):
+ return _escape_markdown_text(value)
+ if isinstance(value, list):
+ return " ".join(
+ part
+ for item in value
+ for part in [_render_nested_post(item, image_keys, media_refs, mentions_map)]
+ if part
+ )
+ if isinstance(value, dict):
+ direct = _render_post_element(value, image_keys, media_refs, mentions_map)
+ if direct:
+ return direct
+ return " ".join(
+ part
+ for item in value.values()
+ for part in [_render_nested_post(item, image_keys, media_refs, mentions_map)]
+ if part
+ )
+ return ""
+
+
+# ---------------------------------------------------------------------------
+# Message normalization
+# ---------------------------------------------------------------------------
+
+
+def normalize_feishu_message(
+ *,
+ message_type: str,
+ raw_content: str,
+ mentions: Optional[Sequence[Any]] = None,
+ bot: _FeishuBotIdentity = _FeishuBotIdentity(),
+) -> FeishuNormalizedMessage:
+ normalized_type = str(message_type or "").strip().lower()
+ payload = _load_feishu_payload(raw_content)
+ mentions_map = _build_mentions_map(mentions, bot)
+
+ if normalized_type == "text":
+ text = str(payload.get("text", "") or "")
+ # Feishu SDK sometimes omits @_all from the mentions payload even when
+ # the text literal contains it (confirmed via im.v1.message.get).
+ if "@_all" in text and "@_all" not in mentions_map:
+ mentions_map["@_all"] = FeishuMentionRef(is_all=True)
+ return FeishuNormalizedMessage(
+ raw_type=normalized_type,
+ text_content=_normalize_feishu_text(text, mentions_map),
+ mentions=list(mentions_map.values()),
+ )
+ if normalized_type == "post":
+ # The walker writes back to mentions_map if it encounters
+ # , so reading .values() after parsing is enough.
+ parsed_post = parse_feishu_post_payload(payload, mentions_map=mentions_map)
+ return FeishuNormalizedMessage(
+ raw_type=normalized_type,
+ text_content=parsed_post.text_content,
+ image_keys=list(parsed_post.image_keys),
+ media_refs=list(parsed_post.media_refs),
+ mentions=list(mentions_map.values()),
+ relation_kind="post",
+ )
+ mention_refs = list(mentions_map.values())
+ if normalized_type == "image":
+ image_key = str(payload.get("image_key", "") or "").strip()
+ alt_text = _normalize_feishu_text(
+ str(payload.get("text", "") or "")
+ or str(payload.get("alt", "") or "")
+ or FALLBACK_IMAGE_TEXT,
+ mentions_map,
+ )
+ return FeishuNormalizedMessage(
+ raw_type=normalized_type,
+ text_content=alt_text if alt_text != FALLBACK_IMAGE_TEXT else "",
+ preferred_message_type="photo",
+ image_keys=[image_key] if image_key else [],
+ relation_kind="image",
+ mentions=mention_refs,
+ )
+ if normalized_type in {"file", "audio", "media"}:
+ media_ref = _build_media_ref_from_payload(payload, resource_type=normalized_type)
+ placeholder = _attachment_placeholder(media_ref.file_name)
+ return FeishuNormalizedMessage(
+ raw_type=normalized_type,
+ text_content="",
+ preferred_message_type="audio" if normalized_type == "audio" else "document",
+ media_refs=[media_ref] if media_ref.file_key else [],
+ relation_kind=normalized_type,
+ metadata={"placeholder_text": placeholder},
+ mentions=mention_refs,
+ )
+ if normalized_type == "merge_forward":
+ return _normalize_merge_forward_message(payload)
+ if normalized_type == "share_chat":
+ return _normalize_share_chat_message(payload)
+ if normalized_type in {"interactive", "card"}:
+ return _normalize_interactive_message(normalized_type, payload)
+
+ return FeishuNormalizedMessage(raw_type=normalized_type, text_content="")
+
+
+def _load_feishu_payload(raw_content: str) -> Dict[str, Any]:
+ try:
+ parsed = json.loads(raw_content) if raw_content else {}
+ except json.JSONDecodeError:
+ return {"text": raw_content}
+ return parsed if isinstance(parsed, dict) else {"content": parsed}
+
+
+def _normalize_merge_forward_message(payload: Dict[str, Any]) -> FeishuNormalizedMessage:
+ title = _first_non_empty_text(
+ payload.get("title"),
+ payload.get("summary"),
+ payload.get("preview"),
+ _find_first_text(payload, keys=("title", "summary", "preview", "description")),
+ )
+ entries = _collect_forward_entries(payload)
+ lines: List[str] = []
+ if title:
+ lines.append(title)
+ lines.extend(entries[:8])
+ text_content = "\n".join(lines).strip() or FALLBACK_FORWARD_TEXT
+ return FeishuNormalizedMessage(
+ raw_type="merge_forward",
+ text_content=text_content,
+ relation_kind="merge_forward",
+ metadata={"entry_count": len(entries), "title": title},
+ )
+
+
+def _normalize_share_chat_message(payload: Dict[str, Any]) -> FeishuNormalizedMessage:
+ chat_name = _first_non_empty_text(
+ payload.get("chat_name"),
+ payload.get("name"),
+ payload.get("title"),
+ _find_first_text(payload, keys=("chat_name", "name", "title")),
+ )
+ share_id = _first_non_empty_text(
+ payload.get("chat_id"),
+ payload.get("open_chat_id"),
+ payload.get("share_chat_id"),
+ )
+ lines = []
+ if chat_name:
+ lines.append(f"Shared chat: {chat_name}")
+ else:
+ lines.append(FALLBACK_SHARE_CHAT_TEXT)
+ if share_id:
+ lines.append(f"Chat ID: {share_id}")
+ text_content = "\n".join(lines)
+ return FeishuNormalizedMessage(
+ raw_type="share_chat",
+ text_content=text_content,
+ relation_kind="share_chat",
+ metadata={"chat_id": share_id, "chat_name": chat_name},
+ )
+
+
+def _normalize_interactive_message(message_type: str, payload: Dict[str, Any]) -> FeishuNormalizedMessage:
+ card_payload = payload.get("card") if isinstance(payload.get("card"), dict) else payload
+ title = _first_non_empty_text(
+ _find_header_title(card_payload),
+ payload.get("title"),
+ _find_first_text(card_payload, keys=("title", "summary", "subtitle")),
+ )
+ body_lines = _collect_card_lines(card_payload)
+ actions = _collect_action_labels(card_payload)
+
+ lines: List[str] = []
+ if title:
+ lines.append(title)
+ for line in body_lines:
+ if line != title:
+ lines.append(line)
+ if actions:
+ lines.append(f"Actions: {', '.join(actions)}")
+
+ text_content = "\n".join(lines[:12]).strip() or FALLBACK_INTERACTIVE_TEXT
+ return FeishuNormalizedMessage(
+ raw_type=message_type,
+ text_content=text_content,
+ relation_kind="interactive",
+ metadata={"title": title, "actions": actions},
+ )
+
+
+# ---------------------------------------------------------------------------
+# Content extraction utilities (card / forward / text walking)
+# ---------------------------------------------------------------------------
+
+
+def _collect_forward_entries(payload: Dict[str, Any]) -> List[str]:
+ candidates: List[Any] = []
+ for key in ("messages", "items", "message_list", "records", "content"):
+ value = payload.get(key)
+ if isinstance(value, list):
+ candidates.extend(value)
+ entries: List[str] = []
+ for item in candidates:
+ if not isinstance(item, dict):
+ text = _normalize_feishu_text(str(item or ""))
+ if text:
+ entries.append(f"- {text}")
+ continue
+ sender = _first_non_empty_text(
+ item.get("sender_name"),
+ item.get("user_name"),
+ item.get("sender"),
+ item.get("name"),
+ )
+ nested_type = str(item.get("message_type", "") or item.get("msg_type", "")).strip().lower()
+ if nested_type == "post":
+ body = parse_feishu_post_payload(item.get("content") or item).text_content
+ else:
+ body = _first_non_empty_text(
+ item.get("text"),
+ item.get("summary"),
+ item.get("preview"),
+ item.get("content"),
+ _find_first_text(item, keys=("text", "content", "summary", "preview", "title")),
+ )
+ body = _normalize_feishu_text(body)
+ if sender and body:
+ entries.append(f"- {sender}: {body}")
+ elif body:
+ entries.append(f"- {body}")
+ return _unique_lines(entries)
+
+
+def _collect_card_lines(payload: Any) -> List[str]:
+ lines = _collect_text_segments(payload, in_rich_block=False)
+ normalized = [_normalize_feishu_text(line) for line in lines]
+ return _unique_lines([line for line in normalized if line])
+
+
+def _collect_action_labels(payload: Any) -> List[str]:
+ labels: List[str] = []
+ for item in _walk_nodes(payload):
+ if not isinstance(item, dict):
+ continue
+ tag = str(item.get("tag", "") or item.get("type", "")).strip().lower()
+ if tag not in {"button", "select_static", "overflow", "date_picker", "picker"}:
+ continue
+ label = _first_non_empty_text(
+ item.get("text"),
+ item.get("name"),
+ item.get("value"),
+ _find_first_text(item, keys=("text", "content", "name", "value")),
+ )
+ if label:
+ labels.append(label)
+ return _unique_lines(labels)
+
+
+def _collect_text_segments(value: Any, *, in_rich_block: bool) -> List[str]:
+ if isinstance(value, str):
+ return [_normalize_feishu_text(value)] if in_rich_block else []
+ if isinstance(value, list):
+ segments: List[str] = []
+ for item in value:
+ segments.extend(_collect_text_segments(item, in_rich_block=in_rich_block))
+ return segments
+ if not isinstance(value, dict):
+ return []
+
+ tag = str(value.get("tag", "") or value.get("type", "")).strip().lower()
+ next_in_rich_block = in_rich_block or tag in {
+ "plain_text",
+ "lark_md",
+ "markdown",
+ "note",
+ "div",
+ "column_set",
+ "column",
+ "action",
+ "button",
+ "select_static",
+ "date_picker",
+ }
+
+ segments: List[str] = []
+ for key in _SUPPORTED_CARD_TEXT_KEYS:
+ item = value.get(key)
+ if isinstance(item, str) and next_in_rich_block:
+ normalized = _normalize_feishu_text(item)
+ if normalized:
+ segments.append(normalized)
+
+ for key, item in value.items():
+ if key in _SKIP_TEXT_KEYS:
+ continue
+ segments.extend(_collect_text_segments(item, in_rich_block=next_in_rich_block))
+ return segments
+
+
+def _build_media_ref_from_payload(payload: Dict[str, Any], *, resource_type: str) -> FeishuPostMediaRef:
+ file_key = str(payload.get("file_key", "") or "").strip()
+ file_name = _first_non_empty_text(
+ payload.get("file_name"),
+ payload.get("title"),
+ payload.get("text"),
+ )
+ effective_type = resource_type if resource_type in {"audio", "video"} else "file"
+ return FeishuPostMediaRef(file_key=file_key, file_name=file_name, resource_type=effective_type)
+
+
+def _attachment_placeholder(file_name: str) -> str:
+ normalized_name = _normalize_feishu_text(file_name)
+ return f"[Attachment: {normalized_name}]" if normalized_name else FALLBACK_ATTACHMENT_TEXT
+
+
+def _find_header_title(payload: Any) -> str:
+ if not isinstance(payload, dict):
+ return ""
+ header = payload.get("header")
+ if not isinstance(header, dict):
+ return ""
+ title = header.get("title")
+ if isinstance(title, dict):
+ return _first_non_empty_text(title.get("content"), title.get("text"), title.get("name"))
+ return _normalize_feishu_text(str(title or ""))
+
+
+def _find_first_text(payload: Any, *, keys: tuple[str, ...]) -> str:
+ for node in _walk_nodes(payload):
+ if not isinstance(node, dict):
+ continue
+ for key in keys:
+ value = node.get(key)
+ if isinstance(value, str):
+ normalized = _normalize_feishu_text(value)
+ if normalized:
+ return normalized
+ return ""
+
+
+def _walk_nodes(value: Any):
+ if isinstance(value, dict):
+ yield value
+ for item in value.values():
+ yield from _walk_nodes(item)
+ elif isinstance(value, list):
+ for item in value:
+ yield from _walk_nodes(item)
+
+
+def _first_non_empty_text(*values: Any) -> str:
+ for value in values:
+ if isinstance(value, str):
+ normalized = _normalize_feishu_text(value)
+ if normalized:
+ return normalized
+ elif value is not None and not isinstance(value, (dict, list)):
+ normalized = _normalize_feishu_text(str(value))
+ if normalized:
+ return normalized
+ return ""
+
+
+# ---------------------------------------------------------------------------
+# General text utilities
+# ---------------------------------------------------------------------------
+
+
+def _normalize_feishu_text(
+ text: str,
+ mentions_map: Optional[Dict[str, FeishuMentionRef]] = None,
+) -> str:
+ def _sub(match: "re.Match[str]") -> str:
+ key = match.group(0)
+ ref = (mentions_map or {}).get(key)
+ if ref is None:
+ return " "
+ name = ref.name or ref.open_id or "user"
+ return f"@{name}"
+
+ cleaned = _MENTION_PLACEHOLDER_RE.sub(_sub, text or "")
+ cleaned = cleaned.replace("@_all", "@all")
+ cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n")
+ cleaned = "\n".join(_WHITESPACE_RE.sub(" ", line).strip() for line in cleaned.split("\n"))
+ cleaned = "\n".join(line for line in cleaned.split("\n") if line)
+ cleaned = _MULTISPACE_RE.sub(" ", cleaned)
+ return cleaned.strip()
+
+
+def _unique_lines(lines: List[str]) -> List[str]:
+ seen: set[str] = set()
+ unique: List[str] = []
+ for line in lines:
+ if not line or line in seen:
+ continue
+ seen.add(line)
+ unique.append(line)
+ return unique
+
+
+# ---------------------------------------------------------------------------
+# Mention helpers
+# ---------------------------------------------------------------------------
+
+
+def _extract_mention_ids(mention: Any) -> tuple[str, str]:
+ # Returns (open_id, user_id). im.v1.message.get hands back id as a string
+ # plus id_type discriminator; event payloads hand back a nested UserId
+ # object carrying both fields.
+ mention_id = getattr(mention, "id", None)
+ if isinstance(mention_id, str):
+ id_type = str(getattr(mention, "id_type", "") or "").lower()
+ if id_type == "open_id":
+ return mention_id, ""
+ if id_type == "user_id":
+ return "", mention_id
+ return "", ""
+ if mention_id is None:
+ return "", ""
+ return (
+ str(getattr(mention_id, "open_id", "") or ""),
+ str(getattr(mention_id, "user_id", "") or ""),
+ )
+
+
+def _build_mentions_map(
+ mentions: Optional[Sequence[Any]],
+ bot: _FeishuBotIdentity,
+) -> Dict[str, FeishuMentionRef]:
+ result: Dict[str, FeishuMentionRef] = {}
+ for mention in mentions or []:
+ key = str(getattr(mention, "key", "") or "")
+ if not key:
+ continue
+ if key == "@_all":
+ result[key] = FeishuMentionRef(is_all=True)
+ continue
+ open_id, user_id = _extract_mention_ids(mention)
+ name = str(getattr(mention, "name", "") or "").strip()
+ result[key] = FeishuMentionRef(
+ name=name,
+ open_id=open_id,
+ is_self=bot.matches(open_id=open_id, user_id=user_id, name=name),
+ )
+ return result
+
+
+def _build_mention_hint(mentions: Sequence[FeishuMentionRef]) -> str:
+ parts: List[str] = []
+ seen: set = set()
+ for ref in mentions:
+ if ref.is_self:
+ continue
+ signature = (ref.is_all, ref.open_id, ref.name)
+ if signature in seen:
+ continue
+ seen.add(signature)
+ if ref.is_all:
+ parts.append("@all")
+ elif ref.open_id:
+ parts.append(f"{ref.name or 'unknown'} (open_id={ref.open_id})")
+ else:
+ parts.append(ref.name or "unknown")
+ return f"[Mentioned: {', '.join(parts)}]" if parts else ""
+
+
+def _strip_edge_self_mentions(
+ text: str,
+ mentions: Sequence[FeishuMentionRef],
+) -> str:
+ # Leading: strip consecutive self-mentions unconditionally.
+ # Trailing: strip only when followed by whitespace/terminal punct, so
+ # mid-sentence references ("don't @Bot again") stay intact.
+ # Leading word-boundary prevents @Al from eating @Alice.
+ if not text:
+ return text
+ self_names = [
+ f"@{ref.name or ref.open_id or 'user'}"
+ for ref in mentions
+ if ref.is_self
+ ]
+ if not self_names:
+ return text
+
+ remaining = text.lstrip()
+ while True:
+ for nm in self_names:
+ if not remaining.startswith(nm):
+ continue
+ after = remaining[len(nm):]
+ if after and after[0] not in _MENTION_BOUNDARY_CHARS:
+ continue
+ remaining = after.lstrip()
+ break
+ else:
+ break
+
+ while True:
+ i = len(remaining)
+ while i > 0 and remaining[i - 1] in _TRAILING_TERMINAL_PUNCT:
+ i -= 1
+ body = remaining[:i]
+ tail = remaining[i:]
+ for nm in self_names:
+ if body.endswith(nm):
+ remaining = body[: -len(nm)].rstrip() + tail
+ break
+ else:
+ return remaining
+
+
+def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None:
+ """Run the official Lark WS client in its own thread-local event loop."""
+ import lark_oapi.ws.client as ws_client_module
+
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ ws_client_module.loop = loop
+ adapter._ws_thread_loop = loop
+
+ original_connect = ws_client_module.websockets.connect
+ original_configure = getattr(ws_client, "_configure", None)
+
+ def _apply_runtime_ws_overrides() -> None:
+ try:
+ setattr(ws_client, "_reconnect_nonce", adapter._ws_reconnect_nonce)
+ setattr(ws_client, "_reconnect_interval", adapter._ws_reconnect_interval)
+ if adapter._ws_ping_interval is not None:
+ setattr(ws_client, "_ping_interval", adapter._ws_ping_interval)
+ except Exception:
+ logger.debug("[Feishu] Failed to apply websocket runtime overrides", exc_info=True)
+
+ def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any:
+ if adapter._ws_ping_interval is not None and "ping_interval" not in kwargs:
+ kwargs["ping_interval"] = adapter._ws_ping_interval
+ if adapter._ws_ping_timeout is not None and "ping_timeout" not in kwargs:
+ kwargs["ping_timeout"] = adapter._ws_ping_timeout
+ return original_connect(*args, **kwargs)
+
+ def _configure_with_overrides(conf: Any) -> Any:
+ if original_configure is None:
+ raise RuntimeError("Feishu _configure_with_overrides called but original_configure is None")
+ result = original_configure(conf)
+ _apply_runtime_ws_overrides()
+ return result
+
+ ws_client_module.websockets.connect = _connect_with_overrides
+ if original_configure is not None:
+ setattr(ws_client, "_configure", _configure_with_overrides)
+ _apply_runtime_ws_overrides()
+ try:
+ ws_client.start()
+ except Exception:
+ pass
+ finally:
+ ws_client_module.websockets.connect = original_connect
+ if original_configure is not None:
+ setattr(ws_client, "_configure", original_configure)
+ pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
+ for task in pending:
+ task.cancel()
+ if pending:
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
+ try:
+ loop.stop()
+ except Exception:
+ pass
+ try:
+ loop.close()
+ except Exception:
+ pass
+ adapter._ws_thread_loop = None
+
+
+def check_feishu_requirements() -> bool:
+ """Check if Feishu/Lark dependencies are available.
+
+ Lazy-installs lark-oapi via ``tools.lazy_deps.ensure("platform.feishu")``
+ on first call if not present. Rebinds all module-level globals on success.
+ """
+ if FEISHU_AVAILABLE:
+ return True
+
+ def _import():
+ import lark_oapi as lark
+ from lark_oapi.api.application.v6 import GetApplicationRequest
+ from lark_oapi.api.im.v1 import (
+ CreateFileRequest, CreateFileRequestBody,
+ CreateImageRequest, CreateImageRequestBody,
+ CreateMessageRequest, CreateMessageRequestBody,
+ GetChatRequest, GetMessageRequest, GetMessageResourceRequest,
+ P2ImMessageMessageReadV1,
+ ReplyMessageRequest, ReplyMessageRequestBody,
+ UpdateMessageRequest, UpdateMessageRequestBody,
+ )
+ from lark_oapi.core import AccessTokenType, HttpMethod
+ from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
+ from lark_oapi.core.model import BaseRequest
+ from lark_oapi.event.callback.model.p2_card_action_trigger import (
+ CallBackCard, P2CardActionTriggerResponse,
+ )
+ from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
+ from lark_oapi.ws import Client as FeishuWSClient
+ return {
+ "lark": lark,
+ "GetApplicationRequest": GetApplicationRequest,
+ "CreateFileRequest": CreateFileRequest,
+ "CreateFileRequestBody": CreateFileRequestBody,
+ "CreateImageRequest": CreateImageRequest,
+ "CreateImageRequestBody": CreateImageRequestBody,
+ "CreateMessageRequest": CreateMessageRequest,
+ "CreateMessageRequestBody": CreateMessageRequestBody,
+ "GetChatRequest": GetChatRequest,
+ "GetMessageRequest": GetMessageRequest,
+ "GetMessageResourceRequest": GetMessageResourceRequest,
+ "P2ImMessageMessageReadV1": P2ImMessageMessageReadV1,
+ "ReplyMessageRequest": ReplyMessageRequest,
+ "ReplyMessageRequestBody": ReplyMessageRequestBody,
+ "UpdateMessageRequest": UpdateMessageRequest,
+ "UpdateMessageRequestBody": UpdateMessageRequestBody,
+ "AccessTokenType": AccessTokenType,
+ "HttpMethod": HttpMethod,
+ "FEISHU_DOMAIN": FEISHU_DOMAIN,
+ "LARK_DOMAIN": LARK_DOMAIN,
+ "BaseRequest": BaseRequest,
+ "CallBackCard": CallBackCard,
+ "P2CardActionTriggerResponse": P2CardActionTriggerResponse,
+ "EventDispatcherHandler": EventDispatcherHandler,
+ "FeishuWSClient": FeishuWSClient,
+ "FEISHU_AVAILABLE": True,
+ }
+
+ from tools.lazy_deps import ensure_and_bind
+ return ensure_and_bind("platform.feishu", _import, globals(), prompt=False)
+
+
+class FeishuAdapter(BasePlatformAdapter):
+ """Feishu/Lark bot adapter."""
+
+ supports_code_blocks = True # Feishu renders fenced code blocks
+ splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)
+
+ MAX_MESSAGE_LENGTH = 8000
+ # Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.
+ CHAT_LOCK_MAX_SIZE: int = 1000
+ # Threshold for detecting Feishu client-side message splits.
+ # When a chunk is near the ~4096-char practical limit, a continuation
+ # is almost certain.
+ _SPLIT_THRESHOLD = 4000
+
+ # =========================================================================
+ # Lifecycle — init / settings / connect / disconnect
+ # =========================================================================
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform.FEISHU)
+
+ self._settings = self._load_settings(config.extra or {})
+ self._apply_settings(self._settings)
+ self._client: Optional[Any] = None
+ # Adapter-owned thread pool for blocking Feishu SDK calls. Routing SDK
+ # work through this pool (instead of asyncio's shared default executor)
+ # means a torn-down default executor can no longer wedge sends with
+ # "Executor shutdown has been called" — the pool is recreated on demand
+ # if it has been shut down. See issue #10849.
+ self._sdk_executor_lock = threading.Lock()
+ self._sdk_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
+ # Set on disconnect/shutdown so a real teardown can't be resurrected
+ # by the recreate-on-shutdown path; cleared on connect for reconnects.
+ self._sdk_executor_closing = False
+ self._ws_client: Optional[Any] = None
+ self._ws_future: Optional[asyncio.Future] = None
+ self._ws_thread_loop: Optional[asyncio.AbstractEventLoop] = None
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
+ self._webhook_runner: Optional[Any] = None
+ self._webhook_site: Optional[Any] = None
+ self._event_handler: Optional[Any] = None
+ self._seen_message_ids: Dict[str, float] = {} # message_id → seen_at (time.time())
+ self._seen_message_order: List[str] = []
+ self._dedup_state_path = get_hermes_home() / "feishu_seen_message_ids.json"
+ self._dedup_lock = threading.Lock()
+ self._sender_name_cache: Dict[str, tuple[str, float]] = {} # sender_id → (name, expire_at)
+ self._webhook_rate_counts: Dict[str, tuple[int, float]] = {} # rate_key → (count, window_start)
+ self._webhook_anomaly_counts: Dict[str, tuple[int, str, float]] = {} # ip → (count, last_status, first_seen)
+ self._card_action_tokens: Dict[str, float] = {} # token → first_seen_time
+ # Inbound events that arrived before the adapter loop was ready
+ # (e.g. during startup/restart or network-flap reconnect). A single
+ # drainer thread replays them as soon as the loop becomes available.
+ self._pending_inbound_events: List[Any] = []
+ self._pending_inbound_lock = threading.Lock()
+ self._pending_drain_scheduled = False
+ self._pending_inbound_max_depth = 1000 # cap queue; drop oldest beyond
+ self._chat_locks: "collections.OrderedDict[str, asyncio.Lock]" = collections.OrderedDict() # chat_id → lock (per-chat serial processing, LRU-bounded)
+ self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing)
+ self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat
+ self._chat_info_cache: Dict[str, Dict[str, Any]] = {}
+ self._message_text_cache: "OrderedDict[str, Optional[str]]" = OrderedDict()
+ self._app_lock_identity: Optional[str] = None
+ self._text_batch_state = FeishuBatchState()
+ self._pending_text_batches = self._text_batch_state.events
+ self._pending_text_batch_tasks = self._text_batch_state.tasks
+ self._pending_text_batch_counts = self._text_batch_state.counts
+ self._media_batch_state = FeishuBatchState()
+ self._pending_media_batches = self._media_batch_state.events
+ self._pending_media_batch_tasks = self._media_batch_state.tasks
+ # Exec approval button state (approval_id → {session_key, message_id, chat_id})
+ self._approval_state: Dict[int, Dict[str, str]] = {}
+ self._approval_counter = itertools.count(1)
+ # Update prompt button state (prompt_id → {session_key, message_id, chat_id})
+ self._update_prompt_state: Dict[int, Dict[str, str]] = {}
+ self._update_prompt_counter = itertools.count(1)
+ # Feishu reaction deletion requires the opaque reaction_id returned
+ # by create, so we cache it per message_id.
+ self._pending_processing_reactions: "OrderedDict[str, str]" = OrderedDict()
+ self._load_seen_message_ids()
+
+ @staticmethod
+ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:
+ # Parse per-group rules from config
+ raw_group_rules = extra.get("group_rules", {})
+ group_rules: Dict[str, FeishuGroupRule] = {}
+ if isinstance(raw_group_rules, dict):
+ for chat_id, rule_cfg in raw_group_rules.items():
+ if not isinstance(rule_cfg, dict):
+ continue
+ # Only override when the key is explicitly set — missing vs false
+ # must not collapse.
+ per_chat_require_mention: Optional[bool] = None
+ if "require_mention" in rule_cfg:
+ per_chat_require_mention = _to_boolean(rule_cfg.get("require_mention"))
+ group_rules[str(chat_id)] = FeishuGroupRule(
+ policy=str(rule_cfg.get("policy", "open")).strip().lower(),
+ allowlist={str(u).strip() for u in rule_cfg.get("allowlist", []) if str(u).strip()},
+ blacklist={str(u).strip() for u in rule_cfg.get("blacklist", []) if str(u).strip()},
+ require_mention=per_chat_require_mention,
+ )
+
+ # Bot-level admins
+ raw_admins = extra.get("admins", [])
+ admins = frozenset(str(u).strip() for u in raw_admins if str(u).strip())
+
+ # Default group policy (for groups not in group_rules)
+ default_group_policy = str(extra.get("default_group_policy", "")).strip().lower()
+
+ # Env-only so adapter and gateway auth bypass share one source; yaml
+ # feishu.allow_bots is bridged to this env var at config load.
+ allow_bots = os.getenv("FEISHU_ALLOW_BOTS", "none").strip().lower()
+ if allow_bots not in {"none", "mentions", "all"}:
+ logger.warning(
+ "[Feishu] Unknown allow_bots=%r, falling back to 'none'. Valid: none, mentions, all.",
+ allow_bots,
+ )
+ allow_bots = "none"
+
+ return FeishuAdapterSettings(
+ app_id=str(extra.get("app_id") or os.getenv("FEISHU_APP_ID", "")).strip(),
+ app_secret=str(extra.get("app_secret") or os.getenv("FEISHU_APP_SECRET", "")).strip(),
+ domain_name=str(extra.get("domain") or os.getenv("FEISHU_DOMAIN", "feishu")).strip().lower(),
+ connection_mode=str(
+ extra.get("connection_mode") or os.getenv("FEISHU_CONNECTION_MODE", "websocket")
+ ).strip().lower(),
+ encrypt_key=str(extra.get("encrypt_key") or os.getenv("FEISHU_ENCRYPT_KEY", "")).strip(),
+ verification_token=str(
+ extra.get("verification_token") or os.getenv("FEISHU_VERIFICATION_TOKEN", "")
+ ).strip(),
+ group_policy=os.getenv("FEISHU_GROUP_POLICY", "allowlist").strip().lower(),
+ allowed_group_users=frozenset(
+ item.strip()
+ for item in os.getenv("FEISHU_ALLOWED_USERS", "").split(",")
+ if item.strip()
+ ),
+ bot_open_id=os.getenv("FEISHU_BOT_OPEN_ID", "").strip(),
+ bot_user_id=os.getenv("FEISHU_BOT_USER_ID", "").strip(),
+ bot_name=os.getenv("FEISHU_BOT_NAME", "").strip(),
+ dedup_cache_size=max(
+ 32,
+ env_int("HERMES_FEISHU_DEDUP_CACHE_SIZE", _DEFAULT_DEDUP_CACHE_SIZE),
+ ),
+ text_batch_delay_seconds=env_float(
+ "HERMES_FEISHU_TEXT_BATCH_DELAY_SECONDS", _DEFAULT_TEXT_BATCH_DELAY_SECONDS
+ ),
+ text_batch_split_delay_seconds=env_float(
+ "HERMES_FEISHU_TEXT_BATCH_SPLIT_DELAY_SECONDS", 2.0
+ ),
+ text_batch_max_messages=max(
+ 1,
+ env_int("HERMES_FEISHU_TEXT_BATCH_MAX_MESSAGES", _DEFAULT_TEXT_BATCH_MAX_MESSAGES),
+ ),
+ text_batch_max_chars=max(
+ 1,
+ env_int("HERMES_FEISHU_TEXT_BATCH_MAX_CHARS", _DEFAULT_TEXT_BATCH_MAX_CHARS),
+ ),
+ media_batch_delay_seconds=env_float(
+ "HERMES_FEISHU_MEDIA_BATCH_DELAY_SECONDS", _DEFAULT_MEDIA_BATCH_DELAY_SECONDS
+ ),
+ webhook_host=str(
+ extra.get("webhook_host") or os.getenv("FEISHU_WEBHOOK_HOST", _DEFAULT_WEBHOOK_HOST)
+ ).strip(),
+ webhook_port=int(
+ extra.get("webhook_port") or os.getenv("FEISHU_WEBHOOK_PORT", str(_DEFAULT_WEBHOOK_PORT))
+ ),
+ webhook_path=(
+ str(extra.get("webhook_path") or os.getenv("FEISHU_WEBHOOK_PATH", _DEFAULT_WEBHOOK_PATH)).strip()
+ or _DEFAULT_WEBHOOK_PATH
+ ),
+ ws_reconnect_nonce=_coerce_required_int(extra.get("ws_reconnect_nonce"), default=30, min_value=0),
+ ws_reconnect_interval=_coerce_required_int(extra.get("ws_reconnect_interval"), default=120, min_value=1),
+ ws_ping_interval=_coerce_int(extra.get("ws_ping_interval"), default=None, min_value=1),
+ ws_ping_timeout=_coerce_int(extra.get("ws_ping_timeout"), default=None, min_value=1),
+ admins=admins,
+ default_group_policy=default_group_policy,
+ group_rules=group_rules,
+ allow_bots=allow_bots,
+ require_mention=_to_boolean(
+ extra.get("require_mention", os.getenv("FEISHU_REQUIRE_MENTION", "true"))
+ ),
+ )
+
+ def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
+ self._app_id = settings.app_id
+ self._app_secret = settings.app_secret
+ self._domain_name = settings.domain_name
+ self._connection_mode = settings.connection_mode
+ self._encrypt_key = settings.encrypt_key
+ self._verification_token = settings.verification_token
+ self._group_policy = settings.group_policy
+ self._allowed_group_users = set(settings.allowed_group_users)
+ self._admins = set(settings.admins)
+ self._default_group_policy = settings.default_group_policy or settings.group_policy
+ self._group_rules = settings.group_rules
+ self._bot_open_id = settings.bot_open_id
+ self._bot_user_id = settings.bot_user_id
+ self._bot_name = settings.bot_name
+ self._dedup_cache_size = settings.dedup_cache_size
+ self._text_batch_delay_seconds = settings.text_batch_delay_seconds
+ self._text_batch_split_delay_seconds = settings.text_batch_split_delay_seconds
+ self._text_batch_max_messages = settings.text_batch_max_messages
+ self._text_batch_max_chars = settings.text_batch_max_chars
+ self._media_batch_delay_seconds = settings.media_batch_delay_seconds
+ self._webhook_host = settings.webhook_host
+ self._webhook_port = settings.webhook_port
+ self._webhook_path = settings.webhook_path
+ self._ws_reconnect_nonce = settings.ws_reconnect_nonce
+ self._ws_reconnect_interval = settings.ws_reconnect_interval
+ self._ws_ping_interval = settings.ws_ping_interval
+ self._ws_ping_timeout = settings.ws_ping_timeout
+ self._allow_bots = settings.allow_bots
+ self._require_mention = settings.require_mention
+
+ def _build_event_handler(self) -> Any:
+ if EventDispatcherHandler is None:
+ return None
+ return (
+ EventDispatcherHandler.builder(
+ self._encrypt_key,
+ self._verification_token,
+ )
+ .register_p2_im_message_message_read_v1(self._on_message_read_event)
+ .register_p2_im_message_receive_v1(self._on_message_event)
+ .register_p2_im_message_reaction_created_v1(
+ lambda data: self._on_reaction_event("im.message.reaction.created_v1", data)
+ )
+ .register_p2_im_message_reaction_deleted_v1(
+ lambda data: self._on_reaction_event("im.message.reaction.deleted_v1", data)
+ )
+ .register_p2_card_action_trigger(self._on_card_action_trigger)
+ .register_p2_im_chat_member_bot_added_v1(self._on_bot_added_to_chat)
+ .register_p2_im_chat_member_bot_deleted_v1(self._on_bot_removed_from_chat)
+ .register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(self._on_p2p_chat_entered)
+ .register_p2_im_message_recalled_v1(self._on_message_recalled)
+ .register_p2_customized_event(
+ "drive.notice.comment_add_v1",
+ self._on_drive_comment_event,
+ )
+ .register_p2_customized_event(
+ "vc.bot.meeting_invited_v1",
+ self._on_meeting_invited_event,
+ )
+ .build()
+ )
+
+ def _get_sdk_executor(self) -> concurrent.futures.ThreadPoolExecutor:
+ """Return the adapter-owned executor for blocking Feishu SDK calls.
+
+ Recreates the pool if it was never built or was shut down by an
+ *external* teardown of the loop's default executor, so that can no
+ longer permanently wedge sends (#10849). Refuses to resurrect once
+ the adapter itself is closing — a real disconnect/shutdown stays shut.
+ """
+ lock = getattr(self, "_sdk_executor_lock", None)
+ if lock is None:
+ lock = threading.Lock()
+ self._sdk_executor_lock = lock
+ with lock:
+ if getattr(self, "_sdk_executor_closing", False):
+ raise RuntimeError("Feishu adapter is shutting down; SDK executor unavailable")
+ executor = getattr(self, "_sdk_executor", None)
+ if executor is None or getattr(executor, "_shutdown", False):
+ executor = concurrent.futures.ThreadPoolExecutor(
+ max_workers=10,
+ thread_name_prefix="hermes-feishu-sdk",
+ )
+ self._sdk_executor = executor
+ return executor
+
+ async def _run_blocking(self, func, *args):
+ """Run a blocking Feishu SDK call on the adapter-owned thread pool."""
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(self._get_sdk_executor(), func, *args)
+
+ def _shutdown_sdk_executor(self) -> None:
+ """Stop the adapter-owned SDK executor without touching the loop default."""
+ lock = getattr(self, "_sdk_executor_lock", None)
+ if lock is None:
+ return
+ with lock:
+ self._sdk_executor_closing = True
+ executor = getattr(self, "_sdk_executor", None)
+ self._sdk_executor = None
+ if executor is None:
+ return
+ try:
+ executor.shutdown(wait=False, cancel_futures=True)
+ except TypeError:
+ executor.shutdown(wait=False)
+
+ async def connect(self, *, is_reconnect: bool = False) -> bool:
+ """Connect to Feishu/Lark."""
+ # A fresh connect (or reconnect) re-arms the SDK executor after a prior
+ # disconnect set the closing flag.
+ self._sdk_executor_closing = False
+ if not FEISHU_AVAILABLE:
+ logger.error("[Feishu] lark-oapi not installed")
+ return False
+ if not self._app_id or not self._app_secret:
+ logger.error("[Feishu] FEISHU_APP_ID or FEISHU_APP_SECRET not set")
+ return False
+ if self._connection_mode not in {"websocket", "webhook"}:
+ logger.error(
+ "[Feishu] Unsupported FEISHU_CONNECTION_MODE=%s. Supported modes: websocket, webhook.",
+ self._connection_mode,
+ )
+ return False
+ if self._connection_mode == "webhook" and not (self._verification_token or self._encrypt_key):
+ logger.error(
+ "[Feishu] Webhook mode requires FEISHU_VERIFICATION_TOKEN or FEISHU_ENCRYPT_KEY."
+ )
+ return False
+
+ try:
+ self._app_lock_identity = self._app_id
+ acquired, existing = acquire_scoped_lock(
+ _FEISHU_APP_LOCK_SCOPE,
+ self._app_lock_identity,
+ metadata={"platform": self.platform.value},
+ )
+ if not acquired:
+ owner_pid = existing.get("pid") if isinstance(existing, dict) else None
+ message = (
+ "Another local Hermes gateway is already using this Feishu app_id"
+ + (f" (PID {owner_pid})." if owner_pid else ".")
+ + " Stop the other gateway before starting a second Feishu websocket client."
+ )
+ logger.error("[Feishu] %s", message)
+ self._set_fatal_error("feishu_app_lock", message, retryable=False)
+ return False
+
+ self._loop = asyncio.get_running_loop()
+ await self._connect_with_retry()
+ self._mark_connected()
+ logger.info("[Feishu] Connected in %s mode (%s)", self._connection_mode, self._domain_name)
+ return True
+ except Exception as exc:
+ await self._release_app_lock()
+ message = f"Feishu startup failed: {exc}"
+ self._set_fatal_error("feishu_connect_error", message, retryable=True)
+ logger.error("[Feishu] Failed to connect: %s", exc, exc_info=True)
+ return False
+
+ async def disconnect(self) -> None:
+ """Disconnect from Feishu/Lark."""
+ self._running = False
+ await self._cancel_pending_tasks(self._pending_text_batch_tasks)
+ await self._cancel_pending_tasks(self._pending_media_batch_tasks)
+ self._reset_batch_buffers()
+
+ # Send a WebSocket CLOSE frame to Feishu BEFORE tearing down the
+ # thread loop. Without this, Feishu's server never learns the
+ # connection is dead and continues routing messages to the stale
+ # endpoint — the channel goes silent until the server-side
+ # CLOSE-WAIT expires (minutes to hours). See issue #10202.
+ #
+ # ``_disable_websocket_auto_reconnect()`` nils ``self._ws_client``,
+ # so capture the client reference first.
+ ws_client = self._ws_client
+ ws_thread_loop = self._ws_thread_loop
+ self._disable_websocket_auto_reconnect()
+ await self._stop_webhook_server()
+
+ if (
+ ws_client is not None
+ and ws_thread_loop is not None
+ and not ws_thread_loop.is_closed()
+ and hasattr(ws_client, "_disconnect")
+ ):
+ try:
+ future = asyncio.run_coroutine_threadsafe(
+ ws_client._disconnect(), ws_thread_loop
+ )
+ # 5s is generous — the CLOSE frame is a single WebSocket
+ # control frame. If it takes longer than that the
+ # connection is already wedged and we gain nothing by
+ # waiting further.
+ await asyncio.wait_for(asyncio.wrap_future(future), timeout=5.0)
+ logger.debug("[Feishu] Sent WebSocket CLOSE frame to Feishu")
+ except asyncio.TimeoutError:
+ logger.warning(
+ "[Feishu] CLOSE frame not acknowledged within 5s — "
+ "Feishu may briefly route messages to the stale "
+ "connection until server-side timeout"
+ )
+ except Exception as exc:
+ logger.debug(
+ "[Feishu] Could not send WebSocket CLOSE frame: %s",
+ exc,
+ exc_info=True,
+ )
+
+ if ws_thread_loop is not None and not ws_thread_loop.is_closed():
+ logger.debug("[Feishu] Cancelling websocket thread tasks and stopping loop")
+
+ def cancel_all_tasks() -> None:
+ tasks = [t for t in asyncio.all_tasks(ws_thread_loop) if not t.done()]
+ logger.debug("[Feishu] Found %d pending tasks in websocket thread", len(tasks))
+ for task in tasks:
+ task.cancel()
+ ws_thread_loop.call_later(0.1, ws_thread_loop.stop)
+
+ ws_thread_loop.call_soon_threadsafe(cancel_all_tasks)
+
+ ws_future = self._ws_future
+ if ws_future is not None:
+ try:
+ logger.debug("[Feishu] Waiting for websocket thread to exit (timeout=10s)")
+ await asyncio.wait_for(asyncio.shield(ws_future), timeout=10.0)
+ logger.debug("[Feishu] Websocket thread exited cleanly")
+ except asyncio.TimeoutError:
+ logger.warning("[Feishu] Websocket thread did not exit within 10s - may be stuck")
+ except asyncio.CancelledError:
+ logger.debug("[Feishu] Websocket thread cancelled during disconnect")
+ except Exception as exc:
+ logger.debug("[Feishu] Websocket thread exited with error: %s", exc, exc_info=True)
+
+ self._ws_future = None
+ self._ws_thread_loop = None
+ self._loop = None
+ self._event_handler = None
+ self._shutdown_sdk_executor()
+ self._persist_seen_message_ids()
+ await self._release_app_lock()
+
+ self._mark_disconnected()
+ logger.info("[Feishu] Disconnected")
+
+ async def _cancel_pending_tasks(self, tasks: Dict[str, asyncio.Task]) -> None:
+ pending = [task for task in tasks.values() if task and not task.done()]
+ for task in pending:
+ task.cancel()
+ if pending:
+ await asyncio.gather(*pending, return_exceptions=True)
+ tasks.clear()
+
+ def _reset_batch_buffers(self) -> None:
+ self._pending_text_batches.clear()
+ self._pending_text_batch_counts.clear()
+ self._pending_media_batches.clear()
+
+ def _disable_websocket_auto_reconnect(self) -> None:
+ if self._ws_client is None:
+ return
+ try:
+ setattr(self._ws_client, "_auto_reconnect", False)
+ except Exception:
+ pass
+ finally:
+ self._ws_client = None
+
+ async def _stop_webhook_server(self) -> None:
+ if self._webhook_runner is None:
+ return
+ try:
+ await self._webhook_runner.cleanup()
+ finally:
+ self._webhook_runner = None
+ self._webhook_site = None
+
+ # =========================================================================
+ # Outbound — send / edit / send_image / send_voice / …
+ # =========================================================================
+
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Send a Feishu message."""
+ if not self._client:
+ return SendResult(success=False, error="Not connected")
+
+ formatted = self.format_message(content)
+ chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
+ last_response = None
+
+ try:
+ for chunk in chunks:
+ msg_type, payload = self._build_outbound_payload(chunk)
+ try:
+ response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type=msg_type,
+ payload=payload,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ except Exception as exc:
+ if msg_type != "post" or not _POST_CONTENT_INVALID_RE.search(str(exc)):
+ raise
+ logger.warning("[Feishu] Invalid post payload rejected by API; falling back to plain text")
+ response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type="text",
+ payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False),
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ if (
+ msg_type == "post"
+ and not self._response_succeeded(response)
+ and _POST_CONTENT_INVALID_RE.search(str(getattr(response, "msg", "") or ""))
+ ):
+ logger.warning("[Feishu] Post payload rejected by API response; falling back to plain text")
+ response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type="text",
+ payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False),
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ last_response = response
+
+ return self._finalize_send_result(last_response, "send failed")
+ except Exception as exc:
+ logger.error("[Feishu] Send error: %s", exc, exc_info=True)
+ return SendResult(success=False, error=str(exc))
+
+ async def edit_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ content: str,
+ *,
+ finalize: bool = False,
+ ) -> SendResult:
+ """Edit a previously sent Feishu text/post message."""
+ if not self._client:
+ return SendResult(success=False, error="Not connected")
+
+ content = self.format_message(content)
+ try:
+ msg_type, payload = self._build_outbound_payload(content)
+ body = self._build_update_message_body(msg_type=msg_type, content=payload)
+ request = self._build_update_message_request(message_id=message_id, request_body=body)
+ response = await self._run_blocking(self._client.im.v1.message.update, request)
+ result = self._finalize_send_result(response, "update failed")
+ if not result.success and msg_type == "post" and _POST_CONTENT_INVALID_RE.search(result.error or ""):
+ logger.warning("[Feishu] Invalid post update payload rejected by API; falling back to plain text")
+ fallback_body = self._build_update_message_body(
+ msg_type="text",
+ content=json.dumps({"text": _strip_markdown_to_plain_text(content)}, ensure_ascii=False),
+ )
+ fallback_request = self._build_update_message_request(message_id=message_id, request_body=fallback_body)
+ fallback_response = await self._run_blocking(self._client.im.v1.message.update, fallback_request)
+ result = self._finalize_send_result(fallback_response, "update failed")
+ if result.success:
+ result.message_id = message_id
+ return result
+ except Exception as exc:
+ logger.error("[Feishu] Failed to edit message %s: %s", message_id, exc, exc_info=True)
+ return SendResult(success=False, error=str(exc))
+
+ async def send_exec_approval(
+ self, chat_id: str, command: str, session_key: str,
+ description: str = "dangerous command",
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Send an interactive card with approval buttons.
+
+ The buttons carry ``hermes_action`` in their value dict so that
+ ``_handle_card_action_event`` can intercept them and call
+ ``resolve_gateway_approval()`` to unblock the waiting agent thread.
+ """
+ if not self._client:
+ return SendResult(success=False, error="Not connected")
+
+ try:
+ approval_id = next(self._approval_counter)
+ cmd_preview = command[:3000] + "..." if len(command) > 3000 else command
+
+ def _btn(label: str, action_name: str, btn_type: str = "default") -> dict:
+ return {
+ "tag": "button",
+ "text": {"tag": "plain_text", "content": label},
+ "type": btn_type,
+ "value": {"hermes_action": action_name, "approval_id": approval_id},
+ }
+
+ card = {
+ "config": {"wide_screen_mode": True},
+ "header": {
+ "title": {"content": "⚠️ Command Approval Required", "tag": "plain_text"},
+ "template": "orange",
+ },
+ "elements": [
+ {
+ "tag": "markdown",
+ "content": f"```\n{cmd_preview}\n```\n**Reason:** {description}",
+ },
+ {
+ "tag": "action",
+ "actions": [
+ _btn("✅ Allow Once", "approve_once", "primary"),
+ _btn("✅ Session", "approve_session"),
+ _btn("✅ Always", "approve_always"),
+ _btn("❌ Deny", "deny", "danger"),
+ ],
+ },
+ ],
+ }
+
+ payload = json.dumps(card, ensure_ascii=False)
+ response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type="interactive",
+ payload=payload,
+ reply_to=None,
+ metadata=metadata,
+ )
+
+ result = self._finalize_send_result(response, "send_exec_approval failed")
+ if result.success:
+ self._approval_state[approval_id] = {
+ "session_key": session_key,
+ "message_id": result.message_id or "",
+ "chat_id": chat_id,
+ }
+ return result
+ except Exception as exc:
+ logger.warning("[Feishu] send_exec_approval failed: %s", exc)
+ return SendResult(success=False, error=str(exc))
+
+ @staticmethod
+ def _build_update_prompt_card(*, prompt: str, default: str, prompt_id: int) -> Dict[str, Any]:
+ default_hint = f"\n\nDefault: `{default}`" if default else ""
+
+ def _btn(label: str, answer: str, btn_type: str) -> dict:
+ return {
+ "tag": "button",
+ "text": {"tag": "plain_text", "content": label},
+ "type": btn_type,
+ "value": {
+ "hermes_update_prompt_action": answer,
+ "update_prompt_id": prompt_id,
+ },
+ }
+
+ return {
+ "config": {"wide_screen_mode": True},
+ "header": {
+ "title": {"content": "⚕ Update Needs Your Input", "tag": "plain_text"},
+ "template": "orange",
+ },
+ "elements": [
+ {"tag": "markdown", "content": f"{prompt}{default_hint}"},
+ {
+ "tag": "action",
+ "actions": [
+ _btn("✓ Yes", "y", "primary"),
+ _btn("✗ No", "n", "danger"),
+ ],
+ },
+ ],
+ }
+
+ async def send_update_prompt(
+ self, chat_id: str, prompt: str, default: str = "",
+ session_key: str = "",
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Send an interactive update prompt with Yes/No buttons."""
+ if not self._client:
+ return SendResult(success=False, error="Not connected")
+
+ try:
+ prompt_id = next(self._update_prompt_counter)
+ payload = json.dumps(
+ self._build_update_prompt_card(prompt=prompt, default=default, prompt_id=prompt_id),
+ ensure_ascii=False,
+ )
+ response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type="interactive",
+ payload=payload,
+ reply_to=None,
+ metadata=metadata,
+ )
+
+ result = self._finalize_send_result(response, "send_update_prompt failed")
+ if result.success:
+ self._update_prompt_state[prompt_id] = {
+ "session_key": session_key,
+ "message_id": result.message_id or "",
+ "chat_id": chat_id,
+ }
+ return result
+ except Exception as exc:
+ logger.warning("[Feishu] send_update_prompt failed: %s", exc)
+ return SendResult(success=False, error=str(exc))
+
+ @staticmethod
+ def _build_resolved_approval_card(*, choice: str, user_name: str) -> Dict[str, Any]:
+ """Build raw card JSON for a resolved approval action."""
+ icon = "❌" if choice == "deny" else "✅"
+ label = _APPROVAL_LABEL_MAP.get(choice, "Resolved")
+ return {
+ "config": {"wide_screen_mode": True},
+ "header": {
+ "title": {"content": f"{icon} {label}", "tag": "plain_text"},
+ "template": "red" if choice == "deny" else "green",
+ },
+ "elements": [
+ {
+ "tag": "markdown",
+ "content": f"{icon} **{label}** by {user_name}",
+ },
+ ],
+ }
+
+ @staticmethod
+ def _build_resolved_update_prompt_card(*, answer: str, user_name: str) -> Dict[str, Any]:
+ yes = answer == "y"
+ label = "Yes" if yes else "No"
+ return {
+ "config": {"wide_screen_mode": True},
+ "header": {
+ "title": {"content": f"{'✅' if yes else '❌'} Update prompt answered: {label}", "tag": "plain_text"},
+ "template": "green" if yes else "red",
+ },
+ "elements": [
+ {"tag": "markdown", "content": f"Answered by **{user_name}**"},
+ ],
+ }
+
+ @staticmethod
+ def _write_update_prompt_response(answer: str) -> None:
+ response_path = get_hermes_home() / ".update_response"
+ tmp_path = response_path.with_suffix(".tmp")
+ tmp_path.write_text(answer)
+ tmp_path.replace(response_path)
+
+ async def send_voice(
+ self,
+ chat_id: str,
+ audio_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send audio to Feishu as a file attachment plus optional caption."""
+ return await self._send_uploaded_file_message(
+ chat_id=chat_id,
+ file_path=audio_path,
+ reply_to=reply_to,
+ metadata=metadata,
+ caption=caption,
+ outbound_message_type="audio",
+ )
+
+ async def send_document(
+ self,
+ chat_id: str,
+ file_path: str,
+ caption: Optional[str] = None,
+ file_name: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send a document/file attachment to Feishu."""
+ return await self._send_uploaded_file_message(
+ chat_id=chat_id,
+ file_path=file_path,
+ reply_to=reply_to,
+ metadata=metadata,
+ caption=caption,
+ file_name=file_name,
+ )
+
+ async def send_video(
+ self,
+ chat_id: str,
+ video_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send a video file to Feishu."""
+ return await self._send_uploaded_file_message(
+ chat_id=chat_id,
+ file_path=video_path,
+ reply_to=reply_to,
+ metadata=metadata,
+ caption=caption,
+ outbound_message_type="media",
+ )
+
+ async def send_image_file(
+ self,
+ chat_id: str,
+ image_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send a local image file to Feishu."""
+ if not self._client:
+ return SendResult(success=False, error="Not connected")
+ if not os.path.exists(image_path):
+ return SendResult(success=False, error=f"Image file not found: {image_path}")
+
+ try:
+ import io as _io
+ with open(image_path, "rb") as f:
+ image_bytes = f.read()
+ # Wrap in BytesIO so lark SDK's MultipartEncoder can read .name and .tell()
+ image_file = _io.BytesIO(image_bytes)
+ image_file.name = os.path.basename(image_path)
+ body = self._build_image_upload_body(
+ image_type=_FEISHU_IMAGE_UPLOAD_TYPE,
+ image=image_file,
+ )
+ request = self._build_image_upload_request(body)
+ upload_response = await self._run_blocking(self._client.im.v1.image.create, request)
+ image_key = self._extract_response_field(upload_response, "image_key")
+ if not image_key:
+ return self._response_error_result(
+ upload_response,
+ default_message="image upload failed",
+ override_error="Feishu image upload missing image_key",
+ )
+
+ if caption:
+ post_payload = self._build_media_post_payload(
+ caption=caption,
+ media_tag={"tag": "img", "image_key": image_key},
+ )
+ message_response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type="post",
+ payload=post_payload,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ else:
+ message_response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type="image",
+ payload=json.dumps({"image_key": image_key}, ensure_ascii=False),
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ return self._finalize_send_result(message_response, "image send failed")
+ except Exception as exc:
+ logger.error("[Feishu] Failed to send image %s: %s", image_path, exc, exc_info=True)
+ return SendResult(success=False, error=str(exc))
+
+ async def send_typing(self, chat_id: str, metadata=None) -> None:
+ """Feishu bot API does not expose a typing indicator."""
+ return None
+
+ async def send_image(
+ self,
+ chat_id: str,
+ image_url: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Download a remote image then send it through the native Feishu image flow."""
+ try:
+ image_path = await self._download_remote_image(image_url)
+ except Exception as exc:
+ logger.error("[Feishu] Failed to download image %s: %s", image_url, exc, exc_info=True)
+ return await super().send_image(
+ chat_id=chat_id,
+ image_url=image_url,
+ caption=caption,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ return await self.send_image_file(
+ chat_id=chat_id,
+ image_path=image_path,
+ caption=caption,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+
+ async def send_animation(
+ self,
+ chat_id: str,
+ animation_url: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Feishu has no native GIF bubble; degrade to a downloadable file."""
+ try:
+ file_path, file_name = await self._download_remote_document(
+ animation_url,
+ default_ext=".gif",
+ preferred_name="animation.gif",
+ )
+ except Exception as exc:
+ logger.error("[Feishu] Failed to download animation %s: %s", animation_url, exc, exc_info=True)
+ return await super().send_animation(
+ chat_id=chat_id,
+ animation_url=animation_url,
+ caption=caption,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ degraded_caption = f"[GIF downgraded to file]\n{caption}" if caption else "[GIF downgraded to file]"
+ return await self.send_document(
+ chat_id=chat_id,
+ file_path=file_path,
+ file_name=file_name,
+ caption=degraded_caption,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+ """Return real chat metadata from Feishu when available."""
+ fallback = {
+ "chat_id": chat_id,
+ "name": chat_id,
+ "type": "dm",
+ }
+ if not self._client:
+ return fallback
+
+ cached = self._chat_info_cache.get(chat_id)
+ if cached is not None:
+ return dict(cached)
+
+ try:
+ request = self._build_get_chat_request(chat_id)
+ response = await self._run_blocking(self._client.im.v1.chat.get, request)
+ if not response or getattr(response, "success", lambda: False)() is False:
+ code = getattr(response, "code", "unknown")
+ msg = getattr(response, "msg", "chat lookup failed")
+ logger.warning("[Feishu] Failed to get chat info for %s: [%s] %s", chat_id, code, msg)
+ return fallback
+
+ data = getattr(response, "data", None)
+ raw_chat_type = str(getattr(data, "chat_type", "") or "").strip().lower()
+ info = {
+ "chat_id": chat_id,
+ "name": str(getattr(data, "name", None) or chat_id),
+ "type": self._map_chat_type(raw_chat_type),
+ "raw_type": raw_chat_type or None,
+ }
+ self._chat_info_cache[chat_id] = info
+ return dict(info)
+ except Exception:
+ logger.warning("[Feishu] Failed to get chat info for %s", chat_id, exc_info=True)
+ return fallback
+
+ def format_message(self, content: str) -> str:
+ """Feishu text messages are plain text by default."""
+ return content.strip()
+
+ # =========================================================================
+ # Inbound event handlers
+ # =========================================================================
+
+ def _on_message_event(self, data: Any) -> None:
+ """Normalize Feishu inbound events into MessageEvent.
+
+ Called by the lark_oapi SDK's event dispatcher on a background thread.
+ If the adapter loop is not currently accepting callbacks (brief window
+ during startup/restart or network-flap reconnect), the event is queued
+ for replay instead of dropped.
+ """
+ loop = self._loop
+ if not self._loop_accepts_callbacks(loop):
+ start_drainer = self._enqueue_pending_inbound_event(data)
+ if start_drainer:
+ threading.Thread(
+ target=self._drain_pending_inbound_events,
+ name="feishu-pending-inbound-drainer",
+ daemon=True,
+ ).start()
+ return
+ self._submit_on_loop(loop, self._handle_message_event_data(data))
+
+ def _enqueue_pending_inbound_event(self, data: Any) -> bool:
+ """Append an event to the pending-inbound queue.
+
+ Returns True if the caller should spawn a drainer thread (no drainer
+ currently scheduled), False if a drainer is already running and will
+ pick up the new event on its next pass.
+ """
+ with self._pending_inbound_lock:
+ if len(self._pending_inbound_events) >= self._pending_inbound_max_depth:
+ # Queue full — drop the oldest to make room. This happens only
+ # if the loop stays unavailable for an extended period AND the
+ # WS keeps firing callbacks. Still better than silent drops.
+ dropped = self._pending_inbound_events.pop(0)
+ try:
+ event = getattr(dropped, "event", None)
+ message = getattr(event, "message", None)
+ message_id = str(getattr(message, "message_id", "") or "unknown")
+ except Exception:
+ message_id = "unknown"
+ logger.error(
+ "[Feishu] Pending-inbound queue full (%d); dropped oldest event %s",
+ self._pending_inbound_max_depth,
+ message_id,
+ )
+ self._pending_inbound_events.append(data)
+ depth = len(self._pending_inbound_events)
+ should_start = not self._pending_drain_scheduled
+ if should_start:
+ self._pending_drain_scheduled = True
+ logger.warning(
+ "[Feishu] Queued inbound event for replay (loop not ready, queue depth=%d)",
+ depth,
+ )
+ return should_start
+
+ def _drain_pending_inbound_events(self) -> None:
+ """Replay queued inbound events once the adapter loop is ready.
+
+ Runs in a dedicated daemon thread. Polls ``_running`` and
+ ``_loop_accepts_callbacks`` until events can be dispatched or the
+ adapter shuts down. A single drainer handles the entire queue;
+ concurrent ``_on_message_event`` calls just append.
+ """
+ poll_interval = 0.25
+ max_wait_seconds = 120.0 # safety cap: drop queue after 2 minutes
+ waited = 0.0
+ try:
+ while True:
+ if not getattr(self, "_running", True):
+ # Adapter shutting down — drop queued events rather than
+ # holding them against a closed loop.
+ with self._pending_inbound_lock:
+ dropped = len(self._pending_inbound_events)
+ self._pending_inbound_events.clear()
+ if dropped:
+ logger.warning(
+ "[Feishu] Dropped %d queued inbound event(s) during shutdown",
+ dropped,
+ )
+ return
+ loop = self._loop
+ if self._loop_accepts_callbacks(loop):
+ with self._pending_inbound_lock:
+ batch = self._pending_inbound_events[:]
+ self._pending_inbound_events.clear()
+ if not batch:
+ # Queue emptied between check and grab; done.
+ with self._pending_inbound_lock:
+ if not self._pending_inbound_events:
+ return
+ continue
+ dispatched = 0
+ requeue: List[Any] = []
+ for event in batch:
+ if self._submit_on_loop(
+ loop, self._handle_message_event_data(event)
+ ):
+ dispatched += 1
+ else:
+ # Loop closed/unavailable — requeue and poll again.
+ requeue.append(event)
+ if requeue:
+ with self._pending_inbound_lock:
+ self._pending_inbound_events[:0] = requeue
+ if dispatched:
+ logger.info(
+ "[Feishu] Replayed %d queued inbound event(s)",
+ dispatched,
+ )
+ if not requeue:
+ # Successfully drained; check if more arrived while
+ # we were dispatching and exit if not.
+ with self._pending_inbound_lock:
+ if not self._pending_inbound_events:
+ return
+ # More events queued or requeue pending — loop again.
+ continue
+ if waited >= max_wait_seconds:
+ with self._pending_inbound_lock:
+ dropped = len(self._pending_inbound_events)
+ self._pending_inbound_events.clear()
+ logger.error(
+ "[Feishu] Adapter loop unavailable for %.0fs; "
+ "dropped %d queued inbound event(s)",
+ max_wait_seconds,
+ dropped,
+ )
+ return
+ time.sleep(poll_interval)
+ waited += poll_interval
+ finally:
+ with self._pending_inbound_lock:
+ self._pending_drain_scheduled = False
+
+ async def _handle_message_event_data(self, data: Any) -> None:
+ """Shared inbound message handling for websocket and webhook transports."""
+ event = getattr(data, "event", None)
+ message = getattr(event, "message", None)
+ sender = getattr(event, "sender", None)
+ if not message or not sender or not getattr(sender, "sender_id", None):
+ logger.debug("[Feishu] Dropping malformed inbound event: missing message/sender")
+ return
+
+ message_id = getattr(message, "message_id", None)
+ if not message_id or self._is_duplicate(message_id):
+ logger.debug("[Feishu] Dropping duplicate/missing message_id: %s", message_id)
+ return
+
+ reason = self._admit(sender, message)
+ if reason is not None:
+ logger.debug("[Feishu] dropping inbound event: %s", reason)
+ return
+
+ chat_type = getattr(message, "chat_type", "p2p")
+ await self._process_inbound_message(
+ data=data,
+ message=message,
+ sender_id=getattr(sender, "sender_id", None),
+ chat_type=chat_type,
+ message_id=message_id,
+ is_bot=_is_bot_sender(sender),
+ )
+
+ def _on_message_read_event(self, data: P2ImMessageMessageReadV1) -> None:
+ """Ignore read-receipt events that Hermes does not act on."""
+ event = getattr(data, "event", None)
+ message = getattr(event, "message", None)
+ message_id = getattr(message, "message_id", None) or ""
+ logger.debug("[Feishu] Ignoring message_read event: %s", message_id)
+
+ def _on_bot_added_to_chat(self, data: Any) -> None:
+ """Handle bot being added to a group chat."""
+ event = getattr(data, "event", None)
+ chat_id = str(getattr(event, "chat_id", "") or "")
+ logger.info("[Feishu] Bot added to chat: %s", chat_id)
+ self._chat_info_cache.pop(chat_id, None)
+
+ def _on_bot_removed_from_chat(self, data: Any) -> None:
+ """Handle bot being removed from a group chat."""
+ event = getattr(data, "event", None)
+ chat_id = str(getattr(event, "chat_id", "") or "")
+ logger.info("[Feishu] Bot removed from chat: %s", chat_id)
+ self._chat_info_cache.pop(chat_id, None)
+
+ def _on_p2p_chat_entered(self, data: Any) -> None:
+ logger.debug("[Feishu] User entered P2P chat with bot")
+
+ def _on_message_recalled(self, data: Any) -> None:
+ logger.debug("[Feishu] Message recalled by user")
+
+ def _on_drive_comment_event(self, data: Any) -> None:
+ """Handle drive document comment notification (drive.notice.comment_add_v1).
+
+ Delegates to :mod:`gateway.platforms.feishu_comment` for parsing,
+ logging, and reaction. Scheduling follows the same
+ ``run_coroutine_threadsafe`` pattern used by ``_on_message_event``.
+ """
+ from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
+
+ loop = self._loop
+ if not self._loop_accepts_callbacks(loop):
+ logger.warning("[Feishu] Dropping drive comment event before adapter loop is ready")
+ return
+ self._submit_on_loop(
+ loop,
+ handle_drive_comment_event(self._client, data, self_open_id=self._bot_open_id),
+ )
+
+ def _on_meeting_invited_event(self, data: Any) -> None:
+ """Handle VC bot meeting invitation notification (vc.bot.meeting_invited_v1)."""
+ from plugins.platforms.feishu.feishu_meeting_invite import handle_meeting_invited_event
+
+ loop = self._loop
+ if not self._loop_accepts_callbacks(loop):
+ logger.warning("[Feishu] Dropping meeting invite event before adapter loop is ready")
+ return
+ self._submit_on_loop(loop, handle_meeting_invited_event(self, data))
+
+ def _on_reaction_event(self, event_type: str, data: Any) -> None:
+ """Route user reactions on bot messages as synthetic text events."""
+ event = getattr(data, "event", None)
+ message_id = str(getattr(event, "message_id", "") or "")
+ operator_type = str(getattr(event, "operator_type", "") or "")
+ reaction_type_obj = getattr(event, "reaction_type", None)
+ emoji_type = str(getattr(reaction_type_obj, "emoji_type", "") or "")
+ action = "added" if "created" in event_type else "removed"
+ logger.debug(
+ "[Feishu] Reaction %s on message %s (operator_type=%s, emoji=%s)",
+ action,
+ message_id,
+ operator_type,
+ emoji_type,
+ )
+ # Drop bot/app-origin reactions to break the feedback loop from our
+ # own lifecycle reactions. A human reacting with the same emoji (e.g.
+ # clicking Typing on a bot message) is still routed through.
+ loop = self._loop
+ if (
+ operator_type in {"bot", "app"}
+ or not message_id
+ or loop is None
+ or bool(getattr(loop, "is_closed", lambda: False)())
+ ):
+ return
+ self._submit_on_loop(loop, self._handle_reaction_event(event_type, data))
+
+ def _on_card_action_trigger(self, data: Any) -> Any:
+ """Handle card-action callback from the Feishu SDK (synchronous).
+
+ For approval actions: parses the event once, returns the resolved card
+ inline (the only reliable way to sync all clients), and schedules a
+ lightweight async method to actually unblock the agent.
+
+ For other card actions: delegates to ``_handle_card_action_event``.
+ """
+ loop = self._loop
+ if not self._loop_accepts_callbacks(loop):
+ logger.warning("[Feishu] Dropping card action before adapter loop is ready")
+ return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
+
+ event = getattr(data, "event", None)
+ action = getattr(event, "action", None)
+ action_value = getattr(action, "value", {}) or {}
+ hermes_action = action_value.get("hermes_action") if isinstance(action_value, dict) else None
+ update_prompt_action = (
+ action_value.get("hermes_update_prompt_action")
+ if isinstance(action_value, dict) else None
+ )
+
+ if hermes_action:
+ return self._handle_approval_card_action(event=event, action_value=action_value, loop=loop)
+ if update_prompt_action:
+ return self._handle_update_prompt_card_action(
+ event=event,
+ action_value=action_value,
+ loop=loop,
+ )
+
+ self._submit_on_loop(loop, self._handle_card_action_event(data))
+ if P2CardActionTriggerResponse is None:
+ return None
+ return P2CardActionTriggerResponse()
+
+ @staticmethod
+ def _loop_accepts_callbacks(loop: Any) -> bool:
+ """Return True when the adapter loop can accept thread-safe submissions."""
+ return loop is not None and not bool(getattr(loop, "is_closed", lambda: False)())
+
+ def _submit_on_loop(self, loop: Any, coro: Any) -> bool:
+ """Schedule background work on the adapter loop with shared failure logging."""
+ from agent.async_utils import safe_schedule_threadsafe
+ future = safe_schedule_threadsafe(
+ coro, loop,
+ logger=logger,
+ log_message="[Feishu] Failed to schedule background callback work",
+ log_level=logging.WARNING,
+ )
+ if future is None:
+ return False
+ future.add_done_callback(self._log_background_failure)
+ return True
+
+ def _is_interactive_operator_authorized(self, open_id: str) -> bool:
+ """Return whether this card-action operator may answer gated prompts."""
+ normalized = str(open_id or "").strip()
+ if not normalized:
+ return False
+ allowed_ids = set(self._admins) | set(self._allowed_group_users)
+ if not allowed_ids:
+ return True
+ return "*" in allowed_ids or normalized in allowed_ids
+
+ def _handle_approval_card_action(self, *, event: Any, action_value: Dict[str, Any], loop: Any) -> Any:
+ """Schedule approval resolution and build the synchronous callback response."""
+ approval_id = action_value.get("approval_id")
+ if approval_id is None:
+ logger.debug("[Feishu] Card action missing approval_id, ignoring")
+ return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
+ state = self._approval_state.get(approval_id)
+ if not state:
+ logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id)
+ return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
+ choice = _APPROVAL_CHOICE_MAP.get(action_value.get("hermes_action"), "deny")
+
+ operator = getattr(event, "operator", None)
+ open_id = str(getattr(operator, "open_id", "") or "")
+ sender_id = SimpleNamespace(open_id=open_id, user_id=str(getattr(operator, "user_id", "") or ""))
+ if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
+ logger.warning("[Feishu] Unauthorized approval click by %s", open_id or "")
+ return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
+
+ callback_chat_id = str(getattr(getattr(event, "context", None), "open_chat_id", "") or "")
+ expected_chat_id = str(state.get("chat_id", "") or "")
+ if callback_chat_id and expected_chat_id and callback_chat_id != expected_chat_id:
+ logger.warning(
+ "[Feishu] Approval callback chat mismatch for %s (expected=%s, got=%s)",
+ approval_id,
+ expected_chat_id,
+ callback_chat_id,
+ )
+ return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
+
+ user_name = self._get_cached_sender_name(open_id) or open_id
+
+ chat_context = getattr(event, "context", None)
+ chat_id = str(getattr(chat_context, "open_chat_id", "") or "")
+ if not self._submit_on_loop(
+ loop,
+ self._resolve_approval(
+ approval_id=approval_id,
+ choice=choice,
+ user_name=user_name,
+ open_id=open_id,
+ chat_id=chat_id,
+ ),
+ ):
+ return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
+
+ if P2CardActionTriggerResponse is None:
+ return None
+ response = P2CardActionTriggerResponse()
+ if CallBackCard is not None:
+ card = CallBackCard()
+ card.type = "raw"
+ card.data = self._build_resolved_approval_card(choice=choice, user_name=user_name)
+ response.card = card
+ return response
+
+ def _handle_update_prompt_card_action(self, *, event: Any, action_value: Dict[str, Any], loop: Any) -> Any:
+ """Schedule update prompt resolution and build the synchronous callback response."""
+ prompt_id = action_value.get("update_prompt_id")
+ if prompt_id is None:
+ logger.debug("[Feishu] Card action missing update_prompt_id, ignoring")
+ return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
+ state = self._update_prompt_state.get(prompt_id)
+ if not state:
+ logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id)
+ return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
+
+ answer = str(action_value.get("hermes_update_prompt_action", "") or "").strip().lower()
+ if answer not in {"y", "n"}:
+ logger.debug("[Feishu] Card action has invalid update prompt answer=%r", answer)
+ return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
+
+ operator = getattr(event, "operator", None)
+ open_id = str(getattr(operator, "open_id", "") or "")
+ sender_id = SimpleNamespace(open_id=open_id, user_id=str(getattr(operator, "user_id", "") or ""))
+ if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
+ logger.warning("[Feishu] Unauthorized update prompt click by %s", open_id or "")
+ return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
+
+ callback_chat_id = str(getattr(getattr(event, "context", None), "open_chat_id", "") or "")
+ expected_chat_id = str(state.get("chat_id", "") or "")
+ if callback_chat_id and expected_chat_id and callback_chat_id != expected_chat_id:
+ logger.warning(
+ "[Feishu] Update prompt callback chat mismatch for %s (expected=%s, got=%s)",
+ prompt_id,
+ expected_chat_id,
+ callback_chat_id,
+ )
+ return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
+
+ user_name = self._get_cached_sender_name(open_id) or open_id
+ if not self._submit_on_loop(
+ loop,
+ self._resolve_update_prompt(
+ prompt_id,
+ answer,
+ user_name,
+ open_id=open_id,
+ chat_id=callback_chat_id,
+ ),
+ ):
+ return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
+
+ if P2CardActionTriggerResponse is None:
+ return None
+ response = P2CardActionTriggerResponse()
+ if CallBackCard is not None:
+ card = CallBackCard()
+ card.type = "raw"
+ card.data = self._build_resolved_update_prompt_card(answer=answer, user_name=user_name)
+ response.card = card
+ return response
+
+ async def _resolve_approval(
+ self,
+ approval_id: Any,
+ choice: str,
+ user_name: str,
+ *,
+ open_id: str = "",
+ chat_id: str = "",
+ ) -> None:
+ """Pop approval state and unblock the waiting agent thread."""
+ state = self._approval_state.get(approval_id)
+ if not state:
+ logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id)
+ return
+ if not self._is_interactive_operator_authorized(open_id):
+ logger.warning("[Feishu] Unauthorized approval click by %s for approval %s", open_id or "", approval_id)
+ return
+ expected_chat_id = str(state.get("chat_id", "") or "")
+ if expected_chat_id and chat_id and expected_chat_id != chat_id:
+ logger.warning(
+ "[Feishu] Approval %s chat mismatch (expected=%s, got=%s)",
+ approval_id, expected_chat_id, chat_id,
+ )
+ return
+ state = self._approval_state.pop(approval_id, None)
+ if not state:
+ logger.debug("[Feishu] Approval %s already resolved while validating callback", approval_id)
+ return
+ try:
+ from tools.approval import resolve_gateway_approval
+ count = resolve_gateway_approval(state["session_key"], choice)
+ logger.info(
+ "Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)",
+ count, state["session_key"], choice, user_name,
+ )
+ except Exception as exc:
+ logger.error("Failed to resolve gateway approval from Feishu button: %s", exc)
+
+ async def _resolve_update_prompt(
+ self,
+ prompt_id: Any,
+ answer: str,
+ user_name: str,
+ *,
+ open_id: str = "",
+ chat_id: str = "",
+ ) -> None:
+ """Persist an update prompt answer for the detached update process."""
+ state = self._update_prompt_state.get(prompt_id)
+ if not state:
+ logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id)
+ return
+ if open_id:
+ sender_id = SimpleNamespace(open_id=open_id, user_id="")
+ if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
+ logger.warning("[Feishu] Unauthorized update prompt click by %s for prompt %s", open_id, prompt_id)
+ return
+ expected_chat_id = str(state.get("chat_id", "") or "")
+ if expected_chat_id and chat_id and expected_chat_id != chat_id:
+ logger.warning(
+ "[Feishu] Update prompt %s chat mismatch (expected=%s, got=%s)",
+ prompt_id,
+ expected_chat_id,
+ chat_id,
+ )
+ return
+ state = self._update_prompt_state.pop(prompt_id, None)
+ if not state:
+ logger.debug("[Feishu] Update prompt %s already resolved while validating callback", prompt_id)
+ return
+ try:
+ self._write_update_prompt_response(answer)
+ logger.info(
+ "Feishu update prompt resolved for session %s (answer=%s, user=%s)",
+ state["session_key"], answer, user_name,
+ )
+ except Exception as exc:
+ logger.error("Failed to resolve Feishu update prompt: %s", exc)
+
+ async def _handle_reaction_event(self, event_type: str, data: Any) -> None:
+ """Fetch the reacted-to message; if it was sent by this bot, emit a synthetic text event."""
+ if not self._client:
+ return
+ event = getattr(data, "event", None)
+ message_id = str(getattr(event, "message_id", "") or "")
+ if not message_id:
+ return
+
+ # Fetch the target message to verify it was sent by us and to obtain chat context.
+ try:
+ request = self._build_get_message_request(message_id)
+ response = await self._run_blocking(self._client.im.v1.message.get, request)
+ if not response or not getattr(response, "success", lambda: False)():
+ return
+ items = getattr(getattr(response, "data", None), "items", None) or []
+ msg = items[0] if items else None
+ if not msg:
+ return
+ # GET im/v1/messages returns sender.id=app_id for bot messages —
+ # peer bots and us share sender_type="app" but differ on app_id.
+ sender = getattr(msg, "sender", None)
+ if str(getattr(sender, "id", "") or "") != self._app_id:
+ return # only route reactions on this bot's own messages
+ chat_id = str(getattr(msg, "chat_id", "") or "")
+ chat_type_raw = str(getattr(msg, "chat_type", "p2p") or "p2p")
+ if not chat_id:
+ return
+ except Exception:
+ logger.debug("[Feishu] Failed to fetch message for reaction routing", exc_info=True)
+ return
+
+ user_id_obj = getattr(event, "user_id", None)
+ reaction_type_obj = getattr(event, "reaction_type", None)
+ emoji_type = str(getattr(reaction_type_obj, "emoji_type", "") or "UNKNOWN")
+ action = "added" if "created" in event_type else "removed"
+ synthetic_text = f"reaction:{action}:{emoji_type}"
+
+ sender_profile = await self._resolve_sender_profile(user_id_obj)
+ chat_info = await self.get_chat_info(chat_id)
+ source = self.build_source(
+ chat_id=chat_id,
+ chat_name=chat_info.get("name") or chat_id or "Feishu Chat",
+ chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type=chat_type_raw),
+ user_id=sender_profile["user_id"],
+ user_name=sender_profile["user_name"],
+ thread_id=None,
+ user_id_alt=sender_profile["user_id_alt"],
+ )
+ synthetic_event = MessageEvent(
+ text=synthetic_text,
+ message_type=MessageType.TEXT,
+ source=source,
+ raw_message=data,
+ message_id=message_id,
+ channel_prompt=self._resolve_channel_prompt(chat_id),
+ timestamp=datetime.now(),
+ )
+ logger.info("[Feishu] Routing reaction %s:%s on bot message %s as synthetic event", action, emoji_type, message_id)
+ await self._handle_message_with_guards(synthetic_event)
+
+ def _is_card_action_duplicate(self, token: str) -> bool:
+ """Return True if this card action token was already processed within the dedup window."""
+ now = time.time()
+ # Prune expired tokens lazily each call.
+ expired = [t for t, ts in self._card_action_tokens.items() if now - ts > _FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS]
+ for t in expired:
+ del self._card_action_tokens[t]
+ if token in self._card_action_tokens:
+ return True
+ self._card_action_tokens[token] = now
+ return False
+
+ async def _handle_card_action_event(self, data: Any) -> None:
+ """Route Feishu interactive card button clicks as synthetic COMMAND events."""
+ event = getattr(data, "event", None)
+ token = str(getattr(event, "token", "") or "")
+ if token and self._is_card_action_duplicate(token):
+ logger.debug("[Feishu] Dropping duplicate card action token: %s", token)
+ return
+
+ context = getattr(event, "context", None)
+ chat_id = str(getattr(context, "open_chat_id", "") or "")
+ operator = getattr(event, "operator", None)
+ open_id = str(getattr(operator, "open_id", "") or "")
+ if not chat_id or not open_id:
+ logger.debug("[Feishu] Card action missing chat_id or operator open_id, dropping")
+ return
+
+ action = getattr(event, "action", None)
+ action_tag = str(getattr(action, "tag", "") or "button")
+ action_value = getattr(action, "value", {}) or {}
+
+ synthetic_text = f"/card {action_tag}"
+ if action_value:
+ try:
+ synthetic_text += f" {json.dumps(action_value, ensure_ascii=False)}"
+ except Exception:
+ pass
+
+ sender_id = SimpleNamespace(open_id=open_id, user_id=None, union_id=None)
+ sender_profile = await self._resolve_sender_profile(sender_id)
+ chat_info = await self.get_chat_info(chat_id)
+ source = self.build_source(
+ chat_id=chat_id,
+ chat_name=chat_info.get("name") or chat_id or "Feishu Chat",
+ chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type="group"),
+ user_id=sender_profile["user_id"],
+ user_name=sender_profile["user_name"],
+ thread_id=None,
+ user_id_alt=sender_profile["user_id_alt"],
+ )
+ synthetic_event = MessageEvent(
+ text=synthetic_text,
+ message_type=MessageType.COMMAND,
+ source=source,
+ raw_message=data,
+ message_id=token or str(uuid.uuid4()),
+ channel_prompt=self._resolve_channel_prompt(chat_id),
+ timestamp=datetime.now(),
+ )
+ logger.info("[Feishu] Routing card action %r from %s in %s as synthetic command", action_tag, open_id, chat_id)
+ await self._handle_message_with_guards(synthetic_event)
+
+ # =========================================================================
+ # Per-chat serialization and typing indicator
+ # =========================================================================
+
+ def _get_chat_lock(self, chat_id: str) -> asyncio.Lock:
+ """Return (creating if needed) the per-chat asyncio.Lock for serial message processing.
+
+ Bounded with LRU eviction so a long-running gateway that sees many
+ distinct chats does not grow ``_chat_locks`` without limit. Locks that
+ are currently held are never evicted; if every entry is locked we fall
+ back to dropping the least-recently-used one.
+ """
+ lock = self._chat_locks.get(chat_id)
+ if lock is not None:
+ self._chat_locks.move_to_end(chat_id)
+ return lock
+ if len(self._chat_locks) >= self.CHAT_LOCK_MAX_SIZE:
+ evicted = False
+ for key in list(self._chat_locks):
+ if not self._chat_locks[key].locked():
+ self._chat_locks.pop(key)
+ evicted = True
+ break
+ if not evicted:
+ self._chat_locks.pop(next(iter(self._chat_locks)))
+ lock = asyncio.Lock()
+ self._chat_locks[chat_id] = lock
+ return lock
+
+ async def _handle_message_with_guards(self, event: MessageEvent) -> None:
+ """Dispatch a single event through the agent pipeline with per-chat serialization
+ before handing the event off to the agent.
+
+ Per-chat lock ensures messages in the same chat are processed one at a
+ time (matches openclaw's createChatQueue serial queue behaviour).
+ """
+ chat_id = getattr(event.source, "chat_id", "") or "" if event.source else ""
+ chat_lock = self._get_chat_lock(chat_id)
+ async with chat_lock:
+ await self.handle_message(event)
+
+ # =========================================================================
+ # Processing status reactions
+ # =========================================================================
+
+ def _reactions_enabled(self) -> bool:
+ return os.getenv("FEISHU_REACTIONS", "true").strip().lower() not in {"false", "0", "no"}
+
+ async def _add_reaction(self, message_id: str, emoji_type: str) -> Optional[str]:
+ """Return the reaction_id on success, else None. The id is needed later for deletion."""
+ if not self._client or not message_id or not emoji_type:
+ return None
+ try:
+ from lark_oapi.api.im.v1 import (
+ CreateMessageReactionRequest,
+ CreateMessageReactionRequestBody,
+ )
+ body = (
+ CreateMessageReactionRequestBody.builder()
+ .reaction_type({"emoji_type": emoji_type})
+ .build()
+ )
+ request = (
+ CreateMessageReactionRequest.builder()
+ .message_id(message_id)
+ .request_body(body)
+ .build()
+ )
+ response = await self._run_blocking(self._client.im.v1.message_reaction.create, request)
+ if response and getattr(response, "success", lambda: False)():
+ data = getattr(response, "data", None)
+ return getattr(data, "reaction_id", None)
+ logger.debug(
+ "[Feishu] Add reaction %s on %s rejected: code=%s msg=%s",
+ emoji_type,
+ message_id,
+ getattr(response, "code", None),
+ getattr(response, "msg", None),
+ )
+ except Exception:
+ logger.warning(
+ "[Feishu] Add reaction %s on %s raised",
+ emoji_type,
+ message_id,
+ exc_info=True,
+ )
+ return None
+
+ async def _remove_reaction(self, message_id: str, reaction_id: str) -> bool:
+ if not self._client or not message_id or not reaction_id:
+ return False
+ try:
+ from lark_oapi.api.im.v1 import DeleteMessageReactionRequest
+ request = (
+ DeleteMessageReactionRequest.builder()
+ .message_id(message_id)
+ .reaction_id(reaction_id)
+ .build()
+ )
+ response = await self._run_blocking(self._client.im.v1.message_reaction.delete, request)
+ if response and getattr(response, "success", lambda: False)():
+ return True
+ logger.debug(
+ "[Feishu] Remove reaction %s on %s rejected: code=%s msg=%s",
+ reaction_id,
+ message_id,
+ getattr(response, "code", None),
+ getattr(response, "msg", None),
+ )
+ except Exception:
+ logger.warning(
+ "[Feishu] Remove reaction %s on %s raised",
+ reaction_id,
+ message_id,
+ exc_info=True,
+ )
+ return False
+
+ def _remember_processing_reaction(self, message_id: str, reaction_id: str) -> None:
+ cache = self._pending_processing_reactions
+ cache[message_id] = reaction_id
+ cache.move_to_end(message_id)
+ while len(cache) > _FEISHU_PROCESSING_REACTION_CACHE_SIZE:
+ cache.popitem(last=False)
+
+ def _pop_processing_reaction(self, message_id: str) -> Optional[str]:
+ return self._pending_processing_reactions.pop(message_id, None)
+
+ async def on_processing_start(self, event: MessageEvent) -> None:
+ if not self._reactions_enabled():
+ return
+ message_id = event.message_id
+ if not message_id or message_id in self._pending_processing_reactions:
+ return
+ reaction_id = await self._add_reaction(message_id, _FEISHU_REACTION_IN_PROGRESS)
+ if reaction_id:
+ self._remember_processing_reaction(message_id, reaction_id)
+
+ async def on_processing_complete(
+ self, event: MessageEvent, outcome: ProcessingOutcome
+ ) -> None:
+ if not self._reactions_enabled():
+ return
+ message_id = event.message_id
+ if not message_id:
+ return
+
+ start_reaction_id = self._pending_processing_reactions.get(message_id)
+ if start_reaction_id:
+ if not await self._remove_reaction(message_id, start_reaction_id):
+ # Don't stack a second badge on top of a Typing we couldn't
+ # remove — UI would read as both "working" and "done/failed"
+ # simultaneously. Keep the handle so LRU eventually evicts it.
+ return
+ self._pop_processing_reaction(message_id)
+
+ if outcome is ProcessingOutcome.FAILURE:
+ await self._add_reaction(message_id, _FEISHU_REACTION_FAILURE)
+
+ # =========================================================================
+ # Webhook server and security
+ # =========================================================================
+
+ def _record_webhook_anomaly(self, remote_ip: str, status: str) -> None:
+ """Increment the anomaly counter for remote_ip and emit a WARNING every threshold hits.
+
+ Mirrors openclaw's createWebhookAnomalyTracker: TTL 6 hours, log every 25 consecutive
+ error responses from the same IP.
+ """
+ now = time.time()
+ entry = self._webhook_anomaly_counts.get(remote_ip)
+ if entry is not None:
+ count, _last_status, first_seen = entry
+ if now - first_seen < _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS:
+ count += 1
+ if count % _FEISHU_WEBHOOK_ANOMALY_THRESHOLD == 0:
+ logger.warning(
+ "[Feishu] Webhook anomaly: %d consecutive error responses (%s) from %s "
+ "over the last %.0fs",
+ count,
+ status,
+ remote_ip,
+ now - first_seen,
+ )
+ self._webhook_anomaly_counts[remote_ip] = (count, status, first_seen)
+ return
+ # Either first occurrence or TTL expired — start fresh.
+ self._webhook_anomaly_counts[remote_ip] = (1, status, now)
+
+ def _clear_webhook_anomaly(self, remote_ip: str) -> None:
+ """Reset the anomaly counter for remote_ip after a successful request."""
+ self._webhook_anomaly_counts.pop(remote_ip, None)
+
+ # =========================================================================
+ # Inbound processing pipeline
+ # =========================================================================
+
+ def _resolve_channel_prompt(self, chat_id: str, parent_id: str | None = None) -> str | None:
+ """Resolve a Feishu per-channel system prompt.
+
+ Mirrors the Discord/Slack behaviour so ``channel_prompts: {:
+ ""}`` in ``PlatformConfig.extra`` is honoured for Feishu chats
+ instead of being silently ignored.
+ """
+ from gateway.platforms.base import resolve_channel_prompt
+ _config = getattr(self, "config", None)
+ _extra = getattr(_config, "extra", None) or {}
+ return resolve_channel_prompt(_extra, chat_id, parent_id)
+
+ async def _process_inbound_message(
+ self,
+ *,
+ data: Any,
+ message: Any,
+ sender_id: Any,
+ chat_type: str,
+ message_id: str,
+ is_bot: bool = False,
+ ) -> None:
+ text, inbound_type, media_urls, media_types, mentions = await self._extract_message_content(message)
+
+ if inbound_type == MessageType.TEXT:
+ text = _strip_edge_self_mentions(text, mentions)
+ if text.startswith("/"):
+ inbound_type = MessageType.COMMAND
+
+ # Guard runs post-strip so a pure "@Bot" message (stripped to "") is dropped.
+ if inbound_type == MessageType.TEXT and not text and not media_urls:
+ logger.debug("[Feishu] Ignoring empty text message id=%s", message_id)
+ return
+
+ if inbound_type != MessageType.COMMAND:
+ hint = _build_mention_hint(mentions)
+ if hint:
+ text = f"{hint}\n\n{text}" if text else hint
+
+ thread_id = getattr(message, "thread_id", None) or getattr(message, "root_id", None) or None
+ reply_to_message_id = (
+ getattr(message, "parent_id", None)
+ or getattr(message, "upper_message_id", None)
+ or getattr(message, "root_id", None)
+ or None
+ )
+ reply_to_text = await self._fetch_message_text(reply_to_message_id) if reply_to_message_id else None
+
+ sender_primary = (
+ getattr(sender_id, "open_id", None)
+ or getattr(sender_id, "user_id", None)
+ or getattr(sender_id, "union_id", None)
+ or ""
+ )
+ logger.info(
+ "[Feishu] Inbound %s message received: id=%s type=%s chat_id=%s sender=%s:%s text=%r media=%d",
+ "dm" if chat_type == "p2p" else "group",
+ message_id,
+ inbound_type.value,
+ getattr(message, "chat_id", "") or "",
+ "bot" if is_bot else "user",
+ sender_primary,
+ text[:120],
+ len(media_urls),
+ )
+
+ chat_id = getattr(message, "chat_id", "") or ""
+ chat_info = await self.get_chat_info(chat_id)
+ sender_profile = await self._resolve_sender_profile(sender_id, is_bot=is_bot)
+ source = self.build_source(
+ chat_id=chat_id,
+ chat_name=chat_info.get("name") or chat_id or "Feishu Chat",
+ chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type=chat_type),
+ user_id=sender_profile["user_id"],
+ user_name=sender_profile["user_name"],
+ thread_id=thread_id,
+ user_id_alt=sender_profile["user_id_alt"],
+ is_bot=is_bot,
+ )
+ normalized = MessageEvent(
+ text=text,
+ message_type=inbound_type,
+ source=source,
+ raw_message=data,
+ message_id=message_id,
+ media_urls=media_urls,
+ media_types=media_types,
+ reply_to_message_id=reply_to_message_id,
+ reply_to_text=reply_to_text,
+ channel_prompt=self._resolve_channel_prompt(chat_id, thread_id or None),
+ timestamp=datetime.now(),
+ )
+ await self._dispatch_inbound_event(normalized)
+
+ async def _dispatch_inbound_event(self, event: MessageEvent) -> None:
+ """Apply Feishu-specific burst protection before entering the base adapter."""
+ if event.message_type == MessageType.TEXT and not event.is_command():
+ await self._enqueue_text_event(event)
+ return
+ if self._should_batch_media_event(event):
+ await self._enqueue_media_event(event)
+ return
+ await self._handle_message_with_guards(event)
+
+ # =========================================================================
+ # Media batching
+ # =========================================================================
+
+ def _should_batch_media_event(self, event: MessageEvent) -> bool:
+ return bool(
+ event.media_urls
+ and event.message_type in {MessageType.PHOTO, MessageType.VIDEO, MessageType.DOCUMENT, MessageType.AUDIO}
+ )
+
+ def _media_batch_key(self, event: MessageEvent) -> str:
+ from gateway.session import build_session_key
+
+ session_key = build_session_key(
+ event.source,
+ group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
+ thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
+ )
+ return f"{session_key}:media:{event.message_type.value}"
+
+ @staticmethod
+ def _media_batch_is_compatible(existing: MessageEvent, incoming: MessageEvent) -> bool:
+ return (
+ existing.message_type == incoming.message_type
+ and existing.reply_to_message_id == incoming.reply_to_message_id
+ and existing.reply_to_text == incoming.reply_to_text
+ and existing.source.thread_id == incoming.source.thread_id
+ )
+
+ async def _enqueue_media_event(self, event: MessageEvent) -> None:
+ key = self._media_batch_key(event)
+ existing = self._pending_media_batches.get(key)
+ if existing is None:
+ self._pending_media_batches[key] = event
+ self._schedule_media_batch_flush(key)
+ return
+ if not self._media_batch_is_compatible(existing, event):
+ await self._flush_media_batch_now(key)
+ self._pending_media_batches[key] = event
+ self._schedule_media_batch_flush(key)
+ return
+ existing.media_urls.extend(event.media_urls)
+ existing.media_types.extend(event.media_types)
+ if event.text:
+ existing.text = self._merge_caption(existing.text, event.text)
+ existing.timestamp = event.timestamp
+ if event.message_id:
+ existing.message_id = event.message_id
+ self._schedule_media_batch_flush(key)
+
+ def _schedule_media_batch_flush(self, key: str) -> None:
+ self._reschedule_batch_task(
+ self._pending_media_batch_tasks,
+ key,
+ self._flush_media_batch,
+ )
+
+ async def _flush_media_batch(self, key: str) -> None:
+ current_task = asyncio.current_task()
+ try:
+ await asyncio.sleep(self._media_batch_delay_seconds)
+ await self._flush_media_batch_now(key)
+ finally:
+ if self._pending_media_batch_tasks.get(key) is current_task:
+ self._pending_media_batch_tasks.pop(key, None)
+
+ async def _flush_media_batch_now(self, key: str) -> None:
+ event = self._pending_media_batches.pop(key, None)
+ if not event:
+ return
+ logger.info(
+ "[Feishu] Flushing media batch %s with %d attachment(s)",
+ key,
+ len(event.media_urls),
+ )
+ await self._handle_message_with_guards(event)
+
+ async def _download_remote_image(self, image_url: str) -> str:
+ ext = self._guess_remote_extension(image_url, default=".jpg")
+ return await cache_image_from_url(image_url, ext=ext)
+
+ async def _download_remote_document(
+ self,
+ file_url: str,
+ *,
+ default_ext: str,
+ preferred_name: str,
+ ) -> tuple[str, str]:
+ from tools.url_safety import is_safe_url
+ if not is_safe_url(file_url):
+ raise ValueError(f"Blocked unsafe URL (SSRF protection): {file_url[:80]}")
+
+ import httpx
+
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
+ response = await client.get(
+ file_url,
+ headers={
+ "User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)",
+ "Accept": "*/*",
+ },
+ )
+ response.raise_for_status()
+ # Snapshot Content-Type and body while the client context is
+ # still active so pooled connections fully release on exit.
+ # See #18451.
+ content_type_hdr = str(response.headers.get("Content-Type", ""))
+ body = response.content
+ filename = self._derive_remote_filename(
+ file_url,
+ content_type=content_type_hdr,
+ default_name=preferred_name,
+ default_ext=default_ext,
+ )
+ cached_path = cache_document_from_bytes(body, filename)
+ return cached_path, filename
+
+ @staticmethod
+ def _guess_remote_extension(url: str, *, default: str) -> str:
+ ext = Path((url or "").split("?", 1)[0]).suffix.lower()
+ return ext if ext in (_IMAGE_EXTENSIONS | _AUDIO_EXTENSIONS | _VIDEO_EXTENSIONS | set(SUPPORTED_DOCUMENT_TYPES)) else default
+
+ @staticmethod
+ def _derive_remote_filename(file_url: str, *, content_type: str, default_name: str, default_ext: str) -> str:
+ candidate = Path((file_url or "").split("?", 1)[0]).name or default_name
+ ext = Path(candidate).suffix.lower()
+ if not ext:
+ guessed = mimetypes.guess_extension((content_type or "").split(";", 1)[0].strip().lower() or "") or default_ext
+ candidate = f"{candidate}{guessed}"
+ return candidate
+
+ @staticmethod
+ def _namespace_from_mapping(value: Any) -> Any:
+ if isinstance(value, dict):
+ return SimpleNamespace(**{key: FeishuAdapter._namespace_from_mapping(item) for key, item in value.items()})
+ if isinstance(value, list):
+ return [FeishuAdapter._namespace_from_mapping(item) for item in value]
+ return value
+
+ async def _handle_webhook_request(self, request: Any) -> Any:
+ remote_ip = (getattr(request, "remote", None) or "unknown")
+
+ # Rate limiting — composite key: app_id:path:remote_ip (matches openclaw key structure).
+ rate_key = f"{self._app_id}:{self._webhook_path}:{remote_ip}"
+ if not self._check_webhook_rate_limit(rate_key):
+ logger.warning("[Feishu] Webhook rate limit exceeded for %s", remote_ip)
+ self._record_webhook_anomaly(remote_ip, "429")
+ return web.Response(status=429, text="Too Many Requests")
+
+ # Content-Type guard — Feishu always sends application/json.
+ headers = getattr(request, "headers", {}) or {}
+ content_type = str(headers.get("Content-Type", "") or "").split(";")[0].strip().lower()
+ if content_type and content_type != "application/json":
+ logger.warning("[Feishu] Webhook rejected: unexpected Content-Type %r from %s", content_type, remote_ip)
+ self._record_webhook_anomaly(remote_ip, "415")
+ return web.Response(status=415, text="Unsupported Media Type")
+
+ # Body size guard — reject early via Content-Length when present.
+ content_length = getattr(request, "content_length", None)
+ if content_length is not None and content_length > _FEISHU_WEBHOOK_MAX_BODY_BYTES:
+ logger.warning("[Feishu] Webhook body too large (%d bytes) from %s", content_length, remote_ip)
+ self._record_webhook_anomaly(remote_ip, "413")
+ return web.Response(status=413, text="Request body too large")
+
+ try:
+ body_bytes: bytes = await asyncio.wait_for(
+ _read_limited_feishu_webhook_body(
+ request,
+ _FEISHU_WEBHOOK_MAX_BODY_BYTES,
+ ),
+ timeout=_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS,
+ )
+ except ValueError:
+ logger.warning("[Feishu] Webhook body exceeds limit from %s", remote_ip)
+ self._record_webhook_anomaly(remote_ip, "413")
+ return web.Response(status=413, text="Request body too large")
+ except asyncio.TimeoutError:
+ logger.warning("[Feishu] Webhook body read timed out after %ds from %s", _FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS, remote_ip)
+ self._record_webhook_anomaly(remote_ip, "408")
+ return web.Response(status=408, text="Request Timeout")
+ except Exception:
+ self._record_webhook_anomaly(remote_ip, "400")
+ return web.json_response({"code": 400, "msg": "failed to read body"}, status=400)
+
+ try:
+ payload = json.loads(body_bytes.decode("utf-8"))
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ self._record_webhook_anomaly(remote_ip, "400")
+ return web.json_response({"code": 400, "msg": "invalid json"}, status=400)
+
+ # Verification token check — second layer of defence beyond signature (matches openclaw).
+ if self._verification_token:
+ header = payload.get("header") or {}
+ incoming_token = str(header.get("token") or payload.get("token") or "")
+ if not incoming_token or not hmac.compare_digest(incoming_token, self._verification_token):
+ logger.warning("[Feishu] Webhook rejected: invalid verification token from %s", remote_ip)
+ self._record_webhook_anomaly(remote_ip, "401-token")
+ return web.Response(status=401, text="Invalid verification token")
+
+ # URL verification challenge — Feishu includes the verification token in
+ # challenge requests. Validate the token (above) before reflecting the
+ # challenge so an unauthenticated remote request cannot prove endpoint
+ # control by getting attacker-supplied challenge data echoed back.
+ if payload.get("type") == "url_verification":
+ return web.json_response({"challenge": payload.get("challenge", "")})
+
+ # Timing-safe signature verification (only enforced when encrypt_key is set).
+ if self._encrypt_key and not self._is_webhook_signature_valid(request.headers, body_bytes):
+ logger.warning("[Feishu] Webhook rejected: invalid signature from %s", remote_ip)
+ self._record_webhook_anomaly(remote_ip, "401-sig")
+ return web.Response(status=401, text="Invalid signature")
+
+ if payload.get("encrypt"):
+ logger.error("[Feishu] Encrypted webhook payloads are not supported by Hermes webhook mode")
+ self._record_webhook_anomaly(remote_ip, "400-encrypted")
+ return web.json_response({"code": 400, "msg": "encrypted webhook payloads are not supported"}, status=400)
+
+ self._clear_webhook_anomaly(remote_ip)
+
+ event_type = str((payload.get("header") or {}).get("event_type") or "")
+ data = self._namespace_from_mapping(payload)
+ if event_type == "im.message.receive_v1":
+ self._on_message_event(data)
+ elif event_type == "im.message.message_read_v1":
+ self._on_message_read_event(data)
+ elif event_type == "im.chat.member.bot.added_v1":
+ self._on_bot_added_to_chat(data)
+ elif event_type == "im.chat.member.bot.deleted_v1":
+ self._on_bot_removed_from_chat(data)
+ elif event_type in {"im.message.reaction.created_v1", "im.message.reaction.deleted_v1"}:
+ self._on_reaction_event(event_type, data)
+ elif event_type == "card.action.trigger":
+ self._on_card_action_trigger(data)
+ elif event_type == "drive.notice.comment_add_v1":
+ self._on_drive_comment_event(data)
+ elif event_type == "vc.bot.meeting_invited_v1":
+ self._on_meeting_invited_event(data)
+ else:
+ logger.debug("[Feishu] Ignoring webhook event type: %s", event_type or "unknown")
+ return web.json_response({"code": 0, "msg": "ok"})
+
+ def _is_webhook_signature_valid(self, headers: Any, body_bytes: bytes) -> bool:
+ """Verify Feishu webhook signature using timing-safe comparison.
+
+ Feishu signature algorithm:
+ SHA256(timestamp + nonce + encrypt_key + body_string)
+ Headers checked: x-lark-request-timestamp, x-lark-request-nonce, x-lark-signature.
+ """
+ timestamp = str(headers.get("x-lark-request-timestamp", "") or "")
+ nonce = str(headers.get("x-lark-request-nonce", "") or "")
+ signature = str(headers.get("x-lark-signature", "") or "")
+ if not timestamp or not nonce or not signature:
+ return False
+ try:
+ body_str = body_bytes.decode("utf-8", errors="replace")
+ content = f"{timestamp}{nonce}{self._encrypt_key}{body_str}"
+ computed = hashlib.sha256(content.encode("utf-8")).hexdigest()
+ return hmac.compare_digest(computed, signature)
+ except Exception:
+ logger.debug("[Feishu] Signature verification raised an exception", exc_info=True)
+ return False
+
+ def _check_webhook_rate_limit(self, rate_key: str) -> bool:
+ """Return False when the composite rate_key has exceeded _FEISHU_WEBHOOK_RATE_LIMIT_MAX.
+
+ The rate_key is composed as "{app_id}:{path}:{remote_ip}" — matching openclaw's key
+ structure so the limit is scoped to a specific (account, endpoint, IP) triple rather
+ than a bare IP, which causes fewer false-positive denials in multi-tenant setups.
+
+ The tracking dict is capped at _FEISHU_WEBHOOK_RATE_MAX_KEYS entries to prevent unbounded
+ memory growth. Stale (expired) entries are pruned when the cap is reached.
+ """
+ now = time.time()
+ # Fast path: existing entry within the current window.
+ entry = self._webhook_rate_counts.get(rate_key)
+ if entry is not None:
+ count, window_start = entry
+ if now - window_start < _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS:
+ if count >= _FEISHU_WEBHOOK_RATE_LIMIT_MAX:
+ return False
+ self._webhook_rate_counts[rate_key] = (count + 1, window_start)
+ return True
+ # New window for an existing key, or a brand-new key — prune stale entries first.
+ if len(self._webhook_rate_counts) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:
+ stale_keys = [
+ k for k, (_, ws) in self._webhook_rate_counts.items()
+ if now - ws >= _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS
+ ]
+ for k in stale_keys:
+ del self._webhook_rate_counts[k]
+ # If still at capacity after pruning, deny untracked keys (fail closed).
+ # The table only fills with this many distinct (account, endpoint, IP)
+ # triples under abuse; allowing untracked requests through at capacity
+ # would let an attacker who flooded the table bypass the limiter entirely.
+ if rate_key not in self._webhook_rate_counts and len(self._webhook_rate_counts) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:
+ logger.warning(
+ "[Feishu] Webhook rate-limit table at capacity (%d keys) — denying untracked key",
+ _FEISHU_WEBHOOK_RATE_MAX_KEYS,
+ )
+ return False
+ self._webhook_rate_counts[rate_key] = (1, now)
+ return True
+
+ # =========================================================================
+ # Text batching
+ # =========================================================================
+
+ def _text_batch_key(self, event: MessageEvent) -> str:
+ """Return the session-scoped key used for Feishu text aggregation."""
+ from gateway.session import build_session_key
+
+ return build_session_key(
+ event.source,
+ group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
+ thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
+ )
+
+ @staticmethod
+ def _text_batch_is_compatible(existing: MessageEvent, incoming: MessageEvent) -> bool:
+ """Only merge text events when reply/thread context is identical."""
+ return (
+ existing.reply_to_message_id == incoming.reply_to_message_id
+ and existing.reply_to_text == incoming.reply_to_text
+ and existing.source.thread_id == incoming.source.thread_id
+ )
+
+ async def _enqueue_text_event(self, event: MessageEvent) -> None:
+ """Debounce rapid Feishu text bursts into a single MessageEvent."""
+ key = self._text_batch_key(event)
+ chunk_len = len(event.text or "")
+ existing = self._pending_text_batches.get(key)
+ if existing is None:
+ event._last_chunk_len = chunk_len # type: ignore[attr-defined]
+ self._pending_text_batches[key] = event
+ self._pending_text_batch_counts[key] = 1
+ self._schedule_text_batch_flush(key)
+ return
+
+ if not self._text_batch_is_compatible(existing, event):
+ await self._flush_text_batch_now(key)
+ self._pending_text_batches[key] = event
+ self._pending_text_batch_counts[key] = 1
+ self._schedule_text_batch_flush(key)
+ return
+
+ existing_count = self._pending_text_batch_counts.get(key, 1)
+ next_count = existing_count + 1
+ appended_text = event.text or ""
+ next_text = f"{existing.text}\n{appended_text}" if existing.text and appended_text else (existing.text or appended_text)
+ if next_count > self._text_batch_max_messages or len(next_text) > self._text_batch_max_chars:
+ await self._flush_text_batch_now(key)
+ self._pending_text_batches[key] = event
+ self._pending_text_batch_counts[key] = 1
+ self._schedule_text_batch_flush(key)
+ return
+
+ existing.text = next_text
+ existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
+ existing.timestamp = event.timestamp
+ if event.message_id:
+ existing.message_id = event.message_id
+ self._pending_text_batch_counts[key] = next_count
+ self._schedule_text_batch_flush(key)
+
+ def _schedule_text_batch_flush(self, key: str) -> None:
+ """Reset the debounce timer for a pending Feishu text batch."""
+ self._reschedule_batch_task(
+ self._pending_text_batch_tasks,
+ key,
+ self._flush_text_batch,
+ )
+
+ @staticmethod
+ def _reschedule_batch_task(
+ task_map: Dict[str, asyncio.Task],
+ key: str,
+ flush_fn: Any,
+ ) -> None:
+ prior_task = task_map.get(key)
+ if prior_task and not prior_task.done():
+ prior_task.cancel()
+ task_map[key] = asyncio.create_task(flush_fn(key))
+
+ async def _flush_text_batch(self, key: str) -> None:
+ """Flush a pending text batch after the quiet period.
+
+ Uses a longer delay when the latest chunk is near Feishu's ~4096-char
+ split point, since a continuation chunk is almost certain.
+ """
+ current_task = asyncio.current_task()
+ try:
+ # Adaptive delay: if the latest chunk is near the split threshold,
+ # a continuation is almost certain — wait longer.
+ pending = self._pending_text_batches.get(key)
+ last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
+ if last_len >= self._SPLIT_THRESHOLD:
+ delay = self._text_batch_split_delay_seconds
+ else:
+ delay = self._text_batch_delay_seconds
+ await asyncio.sleep(delay)
+ await self._flush_text_batch_now(key)
+ finally:
+ if self._pending_text_batch_tasks.get(key) is current_task:
+ self._pending_text_batch_tasks.pop(key, None)
+
+ async def _flush_text_batch_now(self, key: str) -> None:
+ """Dispatch the current text batch immediately."""
+ event = self._pending_text_batches.pop(key, None)
+ self._pending_text_batch_counts.pop(key, None)
+ if not event:
+ return
+ logger.info(
+ "[Feishu] Flushing text batch %s (%d chars)",
+ key,
+ len(event.text or ""),
+ )
+ await self._handle_message_with_guards(event)
+
+ # =========================================================================
+ # Message content extraction and resource download
+ # =========================================================================
+
+ async def _extract_message_content(
+ self, message: Any
+ ) -> tuple[str, MessageType, List[str], List[str], List[FeishuMentionRef]]:
+ raw_content = getattr(message, "content", "") or ""
+ raw_type = getattr(message, "message_type", "") or ""
+ message_id = str(getattr(message, "message_id", "") or "")
+ logger.info("[Feishu] Received raw message type=%s message_id=%s", raw_type, message_id)
+
+ normalized = normalize_feishu_message(
+ message_type=raw_type,
+ raw_content=raw_content,
+ mentions=getattr(message, "mentions", None),
+ bot=self._bot_identity(),
+ )
+ media_urls, media_types = await self._download_feishu_message_resources(
+ message_id=message_id,
+ normalized=normalized,
+ )
+ inbound_type = self._resolve_normalized_message_type(normalized, media_types)
+ text = normalized.text_content
+
+ if (
+ inbound_type in {MessageType.DOCUMENT, MessageType.AUDIO, MessageType.VIDEO, MessageType.PHOTO}
+ and len(media_urls) == 1
+ and normalized.preferred_message_type in {"document", "audio"}
+ ):
+ injected = await self._maybe_extract_text_document(media_urls[0], media_types[0])
+ if injected:
+ text = injected
+
+ return text, inbound_type, media_urls, media_types, list(normalized.mentions)
+
+ async def _download_feishu_message_resources(
+ self,
+ *,
+ message_id: str,
+ normalized: FeishuNormalizedMessage,
+ ) -> tuple[List[str], List[str]]:
+ media_urls: List[str] = []
+ media_types: List[str] = []
+
+ for image_key in normalized.image_keys:
+ cached_path, media_type = await self._download_feishu_image(
+ message_id=message_id,
+ image_key=image_key,
+ )
+ if cached_path:
+ media_urls.append(cached_path)
+ media_types.append(media_type)
+
+ for media_ref in normalized.media_refs:
+ cached_path, media_type = await self._download_feishu_message_resource(
+ message_id=message_id,
+ file_key=media_ref.file_key,
+ resource_type=media_ref.resource_type,
+ fallback_filename=media_ref.file_name,
+ )
+ if cached_path:
+ media_urls.append(cached_path)
+ media_types.append(media_type)
+
+ return media_urls, media_types
+
+ @staticmethod
+ def _resolve_media_message_type(media_type: str, *, default: MessageType) -> MessageType:
+ normalized = (media_type or "").lower()
+ if normalized.startswith("image/"):
+ return MessageType.PHOTO
+ if normalized.startswith("audio/"):
+ return MessageType.AUDIO
+ if normalized.startswith("video/"):
+ return MessageType.VIDEO
+ return default
+
+ def _resolve_normalized_message_type(
+ self,
+ normalized: FeishuNormalizedMessage,
+ media_types: List[str],
+ ) -> MessageType:
+ preferred = normalized.preferred_message_type
+ if preferred == "photo":
+ return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.PHOTO)
+ if preferred == "audio":
+ return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.AUDIO)
+ if preferred == "document":
+ return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.DOCUMENT)
+ return MessageType.TEXT
+
+ async def _maybe_extract_text_document(self, cached_path: str, media_type: str) -> str:
+ if not cached_path or not media_type.startswith("text/"):
+ return ""
+ try:
+ if os.path.getsize(cached_path) > _MAX_TEXT_INJECT_BYTES:
+ return ""
+ ext = Path(cached_path).suffix.lower()
+ if ext not in {".txt", ".md"} and media_type not in {"text/plain", "text/markdown"}:
+ return ""
+ content = Path(cached_path).read_text(encoding="utf-8")
+ display_name = self._display_name_from_cached_path(cached_path)
+ return f"[Content of {display_name}]:\n{content}"
+ except (OSError, UnicodeDecodeError):
+ logger.warning("[Feishu] Failed to inject text document content from %s", cached_path, exc_info=True)
+ return ""
+
+ async def _download_feishu_image(self, *, message_id: str, image_key: str) -> tuple[str, str]:
+ if not self._client or not message_id:
+ return "", ""
+ try:
+ request = self._build_message_resource_request(
+ message_id=message_id,
+ file_key=image_key,
+ resource_type="image",
+ )
+ response = await self._run_blocking(self._client.im.v1.message_resource.get, request)
+ if not response or not response.success():
+ logger.warning(
+ "[Feishu] Failed to download image %s: %s %s",
+ image_key,
+ getattr(response, "code", "unknown"),
+ getattr(response, "msg", "request failed"),
+ )
+ return "", ""
+ raw_bytes = self._read_binary_response(response)
+ if not raw_bytes:
+ return "", ""
+ content_type = self._get_response_header(response, "Content-Type")
+ filename = getattr(response, "file_name", None) or f"{image_key}.jpg"
+ ext = self._guess_extension(filename, content_type, ".jpg", allowed=_IMAGE_EXTENSIONS)
+ cached_path = cache_image_from_bytes(raw_bytes, ext=ext)
+ media_type = self._normalize_media_type(content_type, default=self._default_image_media_type(ext))
+ return cached_path, media_type
+ except Exception:
+ logger.warning("[Feishu] Failed to cache image resource %s", image_key, exc_info=True)
+ return "", ""
+
+ async def _download_feishu_message_resource(
+ self,
+ *,
+ message_id: str,
+ file_key: str,
+ resource_type: str,
+ fallback_filename: str,
+ ) -> tuple[str, str]:
+ if not self._client or not message_id:
+ return "", ""
+
+ request_types = [resource_type]
+ if resource_type in {"audio", "media"}:
+ request_types.append("file")
+
+ for request_type in request_types:
+ try:
+ request = self._build_message_resource_request(
+ message_id=message_id,
+ file_key=file_key,
+ resource_type=request_type,
+ )
+ response = await self._run_blocking(self._client.im.v1.message_resource.get, request)
+ if not response or not response.success():
+ logger.debug(
+ "[Feishu] Resource download failed for %s/%s via type=%s: %s %s",
+ message_id,
+ file_key,
+ request_type,
+ getattr(response, "code", "unknown"),
+ getattr(response, "msg", "request failed"),
+ )
+ continue
+
+ raw_bytes = self._read_binary_response(response)
+ if not raw_bytes:
+ continue
+ content_type = self._get_response_header(response, "Content-Type")
+ response_filename = getattr(response, "file_name", None) or ""
+ filename = response_filename or fallback_filename or f"{request_type}_{file_key}"
+ media_type = self._normalize_media_type(
+ content_type,
+ default=self._guess_media_type_from_filename(filename),
+ )
+
+ if media_type.startswith("image/"):
+ ext = self._guess_extension(filename, content_type, ".jpg", allowed=_IMAGE_EXTENSIONS)
+ cached_path = cache_image_from_bytes(raw_bytes, ext=ext)
+ logger.info("[Feishu] Cached message image resource at %s", cached_path)
+ return cached_path, media_type or self._default_image_media_type(ext)
+
+ if request_type == "audio" or media_type.startswith("audio/"):
+ ext = self._guess_extension(filename, content_type, ".ogg", allowed=_AUDIO_EXTENSIONS)
+ cached_path = cache_audio_from_bytes(raw_bytes, ext=ext)
+ logger.info("[Feishu] Cached message audio resource at %s", cached_path)
+ return cached_path, (media_type or f"audio/{ext.lstrip('.') or 'ogg'}")
+
+ if media_type.startswith("video/"):
+ if not Path(filename).suffix:
+ filename = f"{filename}.mp4"
+ cached_path = cache_document_from_bytes(raw_bytes, filename)
+ logger.info("[Feishu] Cached message video resource at %s", cached_path)
+ return cached_path, media_type
+
+ if not Path(filename).suffix and media_type in _DOCUMENT_MIME_TO_EXT:
+ filename = f"{filename}{_DOCUMENT_MIME_TO_EXT[media_type]}"
+ cached_path = cache_document_from_bytes(raw_bytes, filename)
+ logger.info("[Feishu] Cached message document resource at %s", cached_path)
+ return cached_path, (media_type or self._guess_document_media_type(filename))
+ except Exception:
+ logger.warning(
+ "[Feishu] Failed to cache message resource %s/%s",
+ message_id,
+ file_key,
+ exc_info=True,
+ )
+ return "", ""
+
+ # =========================================================================
+ # Static helpers — extension / media-type guessing
+ # =========================================================================
+
+ @staticmethod
+ def _read_binary_response(response: Any) -> bytes:
+ file_obj = getattr(response, "file", None)
+ if file_obj is None:
+ return b""
+ if hasattr(file_obj, "getvalue"):
+ return bytes(file_obj.getvalue())
+ return bytes(file_obj.read())
+
+ @staticmethod
+ def _get_response_header(response: Any, name: str) -> str:
+ raw = getattr(response, "raw", None)
+ headers = getattr(raw, "headers", {}) or {}
+ return str(headers.get(name, headers.get(name.lower(), "")) or "").split(";", 1)[0].strip().lower()
+
+ @staticmethod
+ def _guess_extension(filename: str, content_type: str, default: str, *, allowed: set[str]) -> str:
+ ext = Path(filename or "").suffix.lower()
+ if ext in allowed:
+ return ext
+ guessed = mimetypes.guess_extension((content_type or "").split(";", 1)[0].strip().lower() or "")
+ if guessed in allowed:
+ return guessed
+ return default
+
+ @staticmethod
+ def _normalize_media_type(content_type: str, *, default: str) -> str:
+ normalized = (content_type or "").split(";", 1)[0].strip().lower()
+ return normalized or default
+
+ @staticmethod
+ def _guess_document_media_type(filename: str) -> str:
+ ext = Path(filename or "").suffix.lower()
+ return SUPPORTED_DOCUMENT_TYPES.get(ext, mimetypes.guess_type(filename or "")[0] or "application/octet-stream")
+
+ @staticmethod
+ def _display_name_from_cached_path(path: str) -> str:
+ basename = os.path.basename(path)
+ parts = basename.split("_", 2)
+ display_name = parts[2] if len(parts) >= 3 else basename
+ return re.sub(r"[^\w.\- ]", "_", display_name)
+
+ @staticmethod
+ def _guess_media_type_from_filename(filename: str) -> str:
+ guessed = (mimetypes.guess_type(filename or "")[0] or "").lower()
+ if guessed:
+ return guessed
+ ext = Path(filename or "").suffix.lower()
+ if ext in _VIDEO_EXTENSIONS:
+ return f"video/{ext.lstrip('.')}"
+ if ext in _AUDIO_EXTENSIONS:
+ return f"audio/{ext.lstrip('.')}"
+ if ext in _IMAGE_EXTENSIONS:
+ return FeishuAdapter._default_image_media_type(ext)
+ return ""
+
+ @staticmethod
+ def _map_chat_type(raw_chat_type: str) -> str:
+ normalized = (raw_chat_type or "").strip().lower()
+ if normalized == "p2p":
+ return "dm"
+ if "topic" in normalized or "thread" in normalized or "forum" in normalized:
+ return "forum"
+ if normalized == "group":
+ return "group"
+ return "dm"
+
+ @staticmethod
+ def _resolve_source_chat_type(*, chat_info: Dict[str, Any], event_chat_type: str) -> str:
+ resolved = str(chat_info.get("type") or "").strip().lower()
+ if resolved in {"group", "forum"}:
+ return resolved
+ if event_chat_type == "p2p":
+ return "dm"
+ return "group"
+
+ async def _resolve_sender_profile(
+ self,
+ sender_id: Any,
+ *,
+ is_bot: bool = False,
+ ) -> Dict[str, Optional[str]]:
+ """Map Feishu's three-tier user IDs onto Hermes' SessionSource fields.
+
+ Preference order for the primary ``user_id`` field:
+ 1. user_id (tenant-scoped, most stable — requires permission scope)
+ 2. open_id (app-scoped, always available — different per bot app)
+
+ ``user_id_alt`` carries the union_id (developer-scoped, stable across
+ all apps by the same developer). Session-key generation prefers
+ user_id_alt when present, so participant isolation stays stable even
+ if the primary ID is the app-scoped open_id.
+ """
+ open_id = getattr(sender_id, "open_id", None) or None
+ user_id = getattr(sender_id, "user_id", None) or None
+ union_id = getattr(sender_id, "union_id", None) or None
+ # Prefer tenant-scoped user_id; fall back to app-scoped open_id.
+ primary_id = user_id or open_id
+ # bot/v3/bots/basic_batch only accepts open_id.
+ name_lookup_id = open_id if is_bot else (primary_id or union_id)
+ display_name = await self._resolve_sender_name_from_api(
+ name_lookup_id, is_bot=is_bot,
+ )
+ return {
+ "user_id": primary_id,
+ "user_name": display_name,
+ "user_id_alt": union_id,
+ }
+
+ def _get_cached_sender_name(self, sender_id: Optional[str]) -> Optional[str]:
+ """Return a cached sender name only while its TTL is still valid."""
+ if not sender_id:
+ return None
+ cached = self._sender_name_cache.get(sender_id)
+ if cached is None:
+ return None
+ name, expire_at = cached
+ if time.time() < expire_at:
+ return name
+ self._sender_name_cache.pop(sender_id, None)
+ return None
+
+ async def _resolve_sender_name_from_api(
+ self,
+ sender_id: Optional[str],
+ *,
+ is_bot: bool = False,
+ ) -> Optional[str]:
+ """Bots divert to bot/basic_batch — contact API doesn't return bot names.
+ Failures are silent so the pipeline never blocks on name resolution.
+ """
+ if not sender_id or not self._client:
+ return None
+ trimmed = sender_id.strip()
+ if not trimmed:
+ return None
+ now = time.time()
+ cached_name = self._get_cached_sender_name(trimmed)
+ if cached_name is not None:
+ return cached_name or None # "" cached means "known nameless"
+ if is_bot:
+ names = await self._fetch_bot_names([trimmed])
+ if names is None:
+ return None
+ expire_at = now + _FEISHU_SENDER_NAME_TTL_SECONDS
+ for oid, name in names.items():
+ self._sender_name_cache[oid] = (name, expire_at)
+ hit = self._sender_name_cache.get(trimmed)
+ return (hit[0] or None) if hit else None
+ try:
+ from lark_oapi.api.contact.v3 import GetUserRequest # lazy import
+ if trimmed.startswith("ou_"):
+ id_type = "open_id"
+ elif trimmed.startswith("on_"):
+ id_type = "union_id"
+ else:
+ id_type = "user_id"
+ request = GetUserRequest.builder().user_id(trimmed).user_id_type(id_type).build()
+ response = await self._run_blocking(self._client.contact.v3.user.get, request)
+ if not response or not response.success():
+ return None
+ user = getattr(getattr(response, "data", None), "user", None)
+ name = (
+ getattr(user, "name", None)
+ or getattr(user, "display_name", None)
+ or getattr(user, "nickname", None)
+ or getattr(user, "en_name", None)
+ )
+ if name and isinstance(name, str):
+ name = name.strip()
+ if name:
+ self._sender_name_cache[trimmed] = (name, now + _FEISHU_SENDER_NAME_TTL_SECONDS)
+ return name
+ except Exception:
+ logger.debug("[Feishu] Failed to resolve sender name for %s", sender_id, exc_info=True)
+ return None
+
+ async def _fetch_bot_names(self, bot_ids: List[str]) -> Optional[Dict[str, str]]:
+ if not self._client or not bot_ids:
+ return None
+ try:
+ req = (
+ BaseRequest.builder()
+ .http_method(HttpMethod.GET)
+ .uri("/open-apis/bot/v3/bots/basic_batch")
+ .queries([("bot_ids", oid) for oid in bot_ids])
+ .token_types({AccessTokenType.TENANT})
+ .build()
+ )
+ resp = await self._run_blocking(self._client.request, req)
+ content = getattr(getattr(resp, "raw", None), "content", None)
+ if not content:
+ return None
+ payload = json.loads(content)
+ if payload.get("code") != 0:
+ return None
+ bots = (payload.get("data") or {}).get("bots") or {}
+ return {
+ oid: str(info.get("name") or "").strip()
+ for oid, info in bots.items()
+ if oid
+ }
+ except Exception:
+ logger.debug("[Feishu] Failed to fetch bot names for %s", bot_ids, exc_info=True)
+ return None
+
+ async def _fetch_message_text(self, message_id: str) -> Optional[str]:
+ if not self._client or not message_id:
+ return None
+ if message_id in self._message_text_cache:
+ self._message_text_cache.move_to_end(message_id)
+ return self._message_text_cache[message_id]
+ try:
+ request = self._build_get_message_request(message_id)
+ response = await self._run_blocking(self._client.im.v1.message.get, request)
+ if not response or getattr(response, "success", lambda: False)() is False:
+ code = getattr(response, "code", "unknown")
+ msg = getattr(response, "msg", "message lookup failed")
+ logger.warning("[Feishu] Failed to fetch parent message %s: [%s] %s", message_id, code, msg)
+ return None
+ items = getattr(getattr(response, "data", None), "items", None) or []
+ parent = items[0] if items else None
+ body = getattr(parent, "body", None)
+ msg_type = getattr(parent, "msg_type", "") or ""
+ raw_content = getattr(body, "content", "") or ""
+ parent_mentions = getattr(parent, "mentions", None) if parent else None
+ text = self._extract_text_from_raw_content(
+ msg_type=msg_type,
+ raw_content=raw_content,
+ mentions=parent_mentions,
+ )
+ self._message_text_cache[message_id] = text
+ while len(self._message_text_cache) > _FEISHU_MESSAGE_TEXT_CACHE_SIZE:
+ self._message_text_cache.popitem(last=False)
+ return text
+ except Exception:
+ logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True)
+ return None
+
+ def _extract_text_from_raw_content(
+ self,
+ *,
+ msg_type: str,
+ raw_content: str,
+ mentions: Optional[Sequence[Any]] = None,
+ ) -> Optional[str]:
+ normalized = normalize_feishu_message(
+ message_type=msg_type,
+ raw_content=raw_content,
+ mentions=mentions,
+ bot=self._bot_identity(),
+ )
+ if normalized.text_content:
+ return normalized.text_content
+ placeholder = normalized.metadata.get("placeholder_text") if isinstance(normalized.metadata, dict) else None
+ return str(placeholder).strip() or None
+
+ @staticmethod
+ def _default_image_media_type(ext: str) -> str:
+ normalized_ext = (ext or "").lower()
+ if normalized_ext in {".jpg", ".jpeg"}:
+ return "image/jpeg"
+ return f"image/{normalized_ext.lstrip('.') or 'jpeg'}"
+
+ @staticmethod
+ def _log_background_failure(future: Any) -> None:
+ try:
+ future.result()
+ except Exception:
+ logger.exception("[Feishu] Background inbound processing failed")
+
+ # =========================================================================
+ # Inbound admission
+ # =========================================================================
+
+ def _admit(self, sender: Any, message: Any) -> Optional[RejectReason]:
+ sender_ids = _sender_identity(sender)
+ self_ids = frozenset(v for v in (self._bot_open_id, self._bot_user_id) if v)
+ is_bot = _is_bot_sender(sender)
+ is_group = getattr(message, "chat_type", "p2p") != "p2p"
+ chat_id = getattr(message, "chat_id", "") or ""
+ require_mention = is_group and self._require_mention_for(chat_id)
+
+ # Defensive only — Feishu doesn't echo our outbound back as inbound,
+ # and open_id is always populated on both sides.
+ if self_ids and sender_ids & self_ids:
+ return "self_echo"
+
+ if is_bot:
+ mode = self._allow_bots
+ if mode != "mentions" and mode != "all":
+ return "bots_disabled"
+ # Defensive: pre-hydration or malformed payloads.
+ if not self_ids or not sender_ids:
+ return "self_ids_unknown"
+ # Step 4 covers mention enforcement for groups when require_mention
+ # is on; check here only on paths step 4 won't reach.
+ if mode == "mentions" and not require_mention and not self._mentions_self(message):
+ return "bot_not_mentioned"
+
+ if not is_group:
+ if os.getenv("FEISHU_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
+ return None
+ if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
+ return None
+ # Empty FEISHU_ALLOWED_USERS is the pairing-mode default from setup:
+ # forward DMs to gateway intake so the pairing handshake can run.
+ # Gateway auth fail-closes agent access until approval.
+ if not self._allowed_group_users:
+ return None
+ if not (sender_ids and (sender_ids & self._allowed_group_users)):
+ return "dm_policy_rejected"
+ return None
+
+ if not self._allow_group_message(
+ getattr(sender, "sender_id", None), chat_id, is_bot=is_bot,
+ ):
+ return "group_policy_rejected"
+ if require_mention and not self._mentions_self(message):
+ return "group_policy_rejected"
+ return None
+
+ def _require_mention_for(self, chat_id: str) -> bool:
+ rule = self._group_rules.get(chat_id) if chat_id else None
+ if rule and rule.require_mention is not None:
+ return rule.require_mention
+ return self._require_mention
+
+ # --- Group policy ---------------------------------------------------------
+
+ def _allow_group_message(
+ self,
+ sender_id: Any,
+ chat_id: str = "",
+ *,
+ is_bot: bool = False,
+ ) -> bool:
+ """Per-group policy gate for non-DM traffic."""
+ sender_open_id = getattr(sender_id, "open_id", None)
+ sender_user_id = getattr(sender_id, "user_id", None)
+ sender_ids = {sender_open_id, sender_user_id} - {None}
+
+ if sender_ids and self._admins and (sender_ids & self._admins):
+ return True
+
+ rule = self._group_rules.get(chat_id) if chat_id else None
+ if rule:
+ policy = rule.policy
+ allowlist = rule.allowlist
+ blacklist = rule.blacklist
+ else:
+ policy = self._default_group_policy or self._group_policy
+ allowlist = self._allowed_group_users
+ blacklist = set()
+
+ # Channel locks apply to everyone; allowlist/blacklist only gate humans
+ # (bots were already cleared upstream by FEISHU_ALLOW_BOTS).
+ if policy == "disabled":
+ return False
+ if policy == "open":
+ return True
+ if policy == "admin_only":
+ return False
+ if is_bot:
+ return True
+
+ if policy == "allowlist":
+ return bool(sender_ids and (sender_ids & allowlist))
+ if policy == "blacklist":
+ return bool(sender_ids and not (sender_ids & blacklist))
+
+ return bool(sender_ids and (sender_ids & self._allowed_group_users))
+
+ # --- Mention detection ----------------------------------------------------
+
+ def _mentions_self(self, message: Any) -> bool:
+ # @_all is Feishu's @everyone placeholder.
+ raw_content = getattr(message, "content", "") or ""
+ if "@_all" in raw_content:
+ return True
+ mentions = getattr(message, "mentions", None) or []
+ if mentions and self._message_mentions_bot(mentions):
+ return True
+ normalized = normalize_feishu_message(
+ message_type=getattr(message, "message_type", "") or "",
+ raw_content=raw_content,
+ mentions=getattr(message, "mentions", None),
+ bot=self._bot_identity(),
+ )
+ return self._post_mentions_bot(normalized.mentions)
+
+ def _message_mentions_bot(self, mentions: List[Any]) -> bool:
+ # IDs trump names: when both sides have open_id (or both user_id),
+ # match requires equal IDs. Name fallback only when either side
+ # lacks an ID.
+ for mention in mentions:
+ mention_id = getattr(mention, "id", None)
+ mention_open_id = (getattr(mention_id, "open_id", None) or "").strip()
+ mention_user_id = (getattr(mention_id, "user_id", None) or "").strip()
+ mention_name = (getattr(mention, "name", None) or "").strip()
+
+ if mention_open_id and self._bot_open_id:
+ if mention_open_id == self._bot_open_id:
+ return True
+ continue # IDs differ — not the bot; skip name fallback.
+ if mention_user_id and self._bot_user_id:
+ if mention_user_id == self._bot_user_id:
+ return True
+ continue
+ if self._bot_name and mention_name == self._bot_name:
+ return True
+
+ return False
+
+ def _post_mentions_bot(self, mentions: List[FeishuMentionRef]) -> bool:
+ return any(m.is_self for m in mentions)
+
+ def _bot_identity(self) -> _FeishuBotIdentity:
+ return _FeishuBotIdentity(
+ open_id=self._bot_open_id,
+ user_id=self._bot_user_id,
+ name=self._bot_name,
+ )
+
+ async def _hydrate_bot_identity(self) -> None:
+ """Best-effort discovery of bot identity for precise group mention gating
+ and self-sent bot event filtering.
+
+ Populates ``_bot_open_id`` and ``_bot_name`` from /open-apis/bot/v3/info
+ (no extra scopes required beyond the tenant access token). The probe
+ always runs when a client is available so stale env vars from app/bot
+ migrations do not break group @mention gating. Falls back to the
+ application info endpoint for ``_bot_name`` only when the first probe
+ doesn't return it. If the probe fails, env-provided values are preserved.
+ """
+ if not self._client:
+ return
+
+ # Primary probe: /open-apis/bot/v3/info — returns bot_name + open_id, no
+ # extra scopes required. This is the same endpoint the onboarding wizard
+ # uses via probe_bot().
+ try:
+ req = (
+ BaseRequest.builder()
+ .http_method(HttpMethod.GET)
+ .uri("/open-apis/bot/v3/info")
+ .token_types({AccessTokenType.TENANT})
+ .build()
+ )
+ resp = await self._run_blocking(self._client.request, req)
+ content = getattr(getattr(resp, "raw", None), "content", None)
+ if content:
+ payload = json.loads(content)
+ parsed = _parse_bot_response(payload) or {}
+ open_id = (parsed.get("bot_open_id") or "").strip()
+ bot_name = (parsed.get("bot_name") or "").strip()
+ if open_id:
+ if self._bot_open_id and self._bot_open_id != open_id:
+ logger.warning(
+ "[Feishu] FEISHU_BOT_OPEN_ID is stale; using /bot/v3/info open_id for group @mention gating."
+ )
+ self._bot_open_id = open_id
+ if bot_name:
+ if self._bot_name and self._bot_name != bot_name:
+ logger.info(
+ "[Feishu] FEISHU_BOT_NAME differs from /bot/v3/info; using hydrated bot name for group @mention gating."
+ )
+ self._bot_name = bot_name
+ except Exception:
+ logger.debug(
+ "[Feishu] /bot/v3/info probe failed during hydration",
+ exc_info=True,
+ )
+
+ # Fallback probe for _bot_name only: application info endpoint. Needs
+ # admin:app.info:readonly or application:application:self_manage scope,
+ # so it's best-effort.
+ if self._bot_name:
+ return
+ try:
+ request = self._build_get_application_request(app_id=self._app_id, lang="en_us")
+ response = await self._run_blocking(self._client.application.v6.application.get, request)
+ if not response or not response.success():
+ code = getattr(response, "code", None)
+ if code == 99991672:
+ logger.warning(
+ "[Feishu] Unable to hydrate bot name from application info. "
+ "Grant admin:app.info:readonly or application:application:self_manage "
+ "so group @mention gating can resolve the bot name precisely."
+ )
+ return
+ app = getattr(getattr(response, "data", None), "app", None)
+ app_name = (getattr(app, "app_name", None) or "").strip()
+ if app_name and not self._bot_name:
+ self._bot_name = app_name
+ except Exception:
+ logger.debug("[Feishu] Failed to hydrate bot name from application info", exc_info=True)
+
+ # =========================================================================
+ # Deduplication — seen message ID cache (persistent)
+ # =========================================================================
+
+ def _load_seen_message_ids(self) -> None:
+ try:
+ payload = json.loads(self._dedup_state_path.read_text(encoding="utf-8"))
+ except FileNotFoundError:
+ return
+ except (OSError, json.JSONDecodeError):
+ logger.warning("[Feishu] Failed to load persisted dedup state from %s", self._dedup_state_path, exc_info=True)
+ return
+ seen_data = payload.get("message_ids", {}) if isinstance(payload, dict) else {}
+ now = time.time()
+ ttl = _FEISHU_DEDUP_TTL_SECONDS
+ # Backward-compat: old format stored a plain list of IDs (no timestamps).
+ if isinstance(seen_data, list):
+ entries: Dict[str, float] = {str(item).strip(): 0.0 for item in seen_data if str(item).strip()}
+ elif isinstance(seen_data, dict):
+ entries = {}
+ for key, value in seen_data.items():
+ if not isinstance(key, str) or not key.strip():
+ continue
+ try:
+ entries[key] = float(value)
+ except (TypeError, ValueError):
+ continue
+ else:
+ return
+ # Filter out TTL-expired entries (entries saved with ts=0.0 are treated as immortal
+ # for one migration cycle to avoid nuking old data on first upgrade).
+ valid: Dict[str, float] = {
+ msg_id: ts for msg_id, ts in entries.items()
+ if ts == 0.0 or ttl <= 0 or now - ts < ttl
+ }
+ # Apply size cap; keep the most recently seen IDs.
+ sorted_ids = sorted(valid, key=lambda k: valid[k], reverse=True)[:self._dedup_cache_size]
+ self._seen_message_order = list(reversed(sorted_ids))
+ self._seen_message_ids = {k: valid[k] for k in sorted_ids}
+
+ def _persist_seen_message_ids(self) -> None:
+ try:
+ self._dedup_state_path.parent.mkdir(parents=True, exist_ok=True)
+ recent = self._seen_message_order[-self._dedup_cache_size:]
+ # Save as {msg_id: timestamp} so TTL filtering works across restarts.
+ payload = {"message_ids": {k: self._seen_message_ids[k] for k in recent if k in self._seen_message_ids}}
+ atomic_json_write(self._dedup_state_path, payload, indent=None)
+ except OSError:
+ logger.warning("[Feishu] Failed to persist dedup state to %s", self._dedup_state_path, exc_info=True)
+
+ def _is_duplicate(self, message_id: str) -> bool:
+ now = time.time()
+ ttl = _FEISHU_DEDUP_TTL_SECONDS
+ with self._dedup_lock:
+ seen_at = self._seen_message_ids.get(message_id)
+ if seen_at is not None and (ttl <= 0 or now - seen_at < ttl):
+ return True
+ # Record with current wall-clock timestamp so TTL works across restarts.
+ self._seen_message_ids[message_id] = now
+ self._seen_message_order.append(message_id)
+ while len(self._seen_message_order) > self._dedup_cache_size:
+ stale = self._seen_message_order.pop(0)
+ self._seen_message_ids.pop(stale, None)
+ self._persist_seen_message_ids()
+ return False
+
+ # =========================================================================
+ # Outbound payload construction and send pipeline
+ # =========================================================================
+
+ def _build_outbound_payload(self, content: str) -> tuple[str, str]:
+ if _MARKDOWN_HINT_RE.search(content):
+ return "post", _build_markdown_post_payload(content)
+ text_payload = {"text": content}
+ return "text", json.dumps(text_payload, ensure_ascii=False)
+
+ async def _send_uploaded_file_message(
+ self,
+ *,
+ chat_id: str,
+ file_path: str,
+ reply_to: Optional[str],
+ metadata: Optional[Dict[str, Any]],
+ caption: Optional[str] = None,
+ file_name: Optional[str] = None,
+ outbound_message_type: str = "file",
+ ) -> SendResult:
+ if not self._client:
+ return SendResult(success=False, error="Not connected")
+ if not os.path.exists(file_path):
+ return SendResult(success=False, error=f"File not found: {file_path}")
+
+ display_name = file_name or os.path.basename(file_path)
+ upload_file_type, resolved_message_type = self._resolve_outbound_file_routing(
+ file_path=display_name,
+ requested_message_type=outbound_message_type,
+ )
+ try:
+ with open(file_path, "rb") as file_obj:
+ body = self._build_file_upload_body(
+ file_type=upload_file_type,
+ file_name=display_name,
+ file=file_obj,
+ )
+ request = self._build_file_upload_request(body)
+ upload_response = await self._run_blocking(self._client.im.v1.file.create, request)
+ file_key = self._extract_response_field(upload_response, "file_key")
+ if not file_key:
+ return self._response_error_result(
+ upload_response,
+ default_message="file upload failed",
+ override_error="Feishu file upload missing file_key",
+ )
+
+ if caption:
+ media_tag = {
+ "tag": "media",
+ "file_key": file_key,
+ "file_name": display_name,
+ }
+ message_response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type="post",
+ payload=self._build_media_post_payload(caption=caption, media_tag=media_tag),
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ else:
+ message_response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type=resolved_message_type,
+ payload=json.dumps({"file_key": file_key}, ensure_ascii=False),
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ return self._finalize_send_result(message_response, "file send failed")
+ except Exception as exc:
+ logger.error("[Feishu] Failed to send file %s: %s", file_path, exc, exc_info=True)
+ return SendResult(success=False, error=str(exc))
+
+ async def _send_raw_message(
+ self,
+ *,
+ chat_id: str,
+ msg_type: str,
+ payload: str,
+ reply_to: Optional[str],
+ metadata: Optional[Dict[str, Any]],
+ ) -> Any:
+ effective_reply_to = reply_to
+ if not effective_reply_to and metadata and metadata.get("thread_id"):
+ effective_reply_to = metadata.get("reply_to_message_id")
+ reply_in_thread = bool((metadata or {}).get("thread_id"))
+ if effective_reply_to:
+ body = self._build_reply_message_body(
+ content=payload,
+ msg_type=msg_type,
+ reply_in_thread=reply_in_thread,
+ uuid_value=str(uuid.uuid4()),
+ )
+ request = self._build_reply_message_request(effective_reply_to, body)
+ return await self._run_blocking(self._client.im.v1.message.reply, request)
+
+ # For topic/thread messages that fell back from reply→create, use
+ # thread_id as receive_id so the message lands in the topic instead of
+ # the main chat.
+ _thread_id = (metadata or {}).get("thread_id")
+ if _thread_id:
+ body = self._build_create_message_body(
+ receive_id=_thread_id,
+ msg_type=msg_type,
+ content=payload,
+ uuid_value=str(uuid.uuid4()),
+ )
+ request = self._build_create_message_request("thread_id", body)
+ else:
+ receive_id = chat_id
+ receive_id_type = "chat_id"
+ if chat_id.startswith("feishu_user_id:"):
+ receive_id = chat_id.split(":", 1)[1]
+ receive_id_type = "user_id"
+ elif chat_id.startswith("ou_"):
+ receive_id_type = "open_id"
+
+ body = self._build_create_message_body(
+ receive_id=receive_id,
+ msg_type=msg_type,
+ content=payload,
+ uuid_value=str(uuid.uuid4()),
+ )
+ request = self._build_create_message_request(receive_id_type, body)
+ return await self._run_blocking(self._client.im.v1.message.create, request)
+
+ @staticmethod
+ def _response_succeeded(response: Any) -> bool:
+ return bool(response and getattr(response, "success", lambda: False)())
+
+ @staticmethod
+ def _extract_response_field(response: Any, field_name: str) -> Any:
+ if not FeishuAdapter._response_succeeded(response):
+ return None
+ data = getattr(response, "data", None)
+ return getattr(data, field_name, None) if data else None
+
+ def _response_error_result(
+ self,
+ response: Any,
+ *,
+ default_message: str,
+ override_error: Optional[str] = None,
+ ) -> SendResult:
+ if override_error:
+ return SendResult(success=False, error=override_error, raw_response=response)
+ code = getattr(response, "code", "unknown")
+ msg = getattr(response, "msg", default_message)
+ return SendResult(success=False, error=f"[{code}] {msg}", raw_response=response)
+
+ def _finalize_send_result(self, response: Any, default_message: str) -> SendResult:
+ if not self._response_succeeded(response):
+ return self._response_error_result(response, default_message=default_message)
+ return SendResult(
+ success=True,
+ message_id=self._extract_response_field(response, "message_id"),
+ raw_response=response,
+ )
+
+ # =========================================================================
+ # Connection internals — websocket / webhook setup
+ # =========================================================================
+
+ async def _connect_with_retry(self) -> None:
+ for attempt in range(_FEISHU_CONNECT_ATTEMPTS):
+ try:
+ if self._connection_mode == "websocket":
+ await self._connect_websocket()
+ else:
+ await self._connect_webhook()
+ return
+ except Exception as exc:
+ self._running = False
+ self._disable_websocket_auto_reconnect()
+ self._ws_future = None
+ await self._stop_webhook_server()
+ if attempt >= _FEISHU_CONNECT_ATTEMPTS - 1:
+ raise
+ wait_seconds = 2 ** attempt
+ logger.warning(
+ "[Feishu] Connect attempt %d/%d failed; retrying in %ds: %s",
+ attempt + 1,
+ _FEISHU_CONNECT_ATTEMPTS,
+ wait_seconds,
+ exc,
+ )
+ await asyncio.sleep(wait_seconds)
+
+ async def _connect_websocket(self) -> None:
+ if not FEISHU_WEBSOCKET_AVAILABLE:
+ raise RuntimeError("websockets not installed; websocket mode unavailable")
+ domain = FEISHU_DOMAIN if self._domain_name != "lark" else LARK_DOMAIN
+ self._client = self._build_lark_client(domain)
+ self._event_handler = self._build_event_handler()
+ if self._event_handler is None:
+ raise RuntimeError("failed to build Feishu event handler")
+ loop = self._loop
+ if loop is None or loop.is_closed():
+ raise RuntimeError("adapter loop is not ready")
+ await self._hydrate_bot_identity()
+ self._ws_client = FeishuWSClient(
+ app_id=self._app_id,
+ app_secret=self._app_secret,
+ log_level=lark.LogLevel.INFO,
+ event_handler=self._event_handler,
+ domain=domain,
+ # Channel SDK signaling tag: without this UA tag the Feishu
+ # server does not push group @mention events over the WebSocket
+ # transport. The tag tells the server to use the Channel protocol
+ # which enables group-message routing in addition to P2P DM.
+ # See https://github.com/NousResearch/hermes-agent/issues/50656
+ extra_ua_tags=["channel"],
+ )
+ self._ws_future = loop.run_in_executor(
+ None,
+ _run_official_feishu_ws_client,
+ self._ws_client,
+ self,
+ )
+
+ async def _connect_webhook(self) -> None:
+ if not FEISHU_WEBHOOK_AVAILABLE:
+ raise RuntimeError("aiohttp not installed; webhook mode unavailable")
+ domain = FEISHU_DOMAIN if self._domain_name != "lark" else LARK_DOMAIN
+ self._client = self._build_lark_client(domain)
+ self._event_handler = self._build_event_handler()
+ if self._event_handler is None:
+ raise RuntimeError("failed to build Feishu event handler")
+ await self._hydrate_bot_identity()
+ # client_max_size backstops the bounded reader in
+ # _handle_webhook_request; aiohttp then enforces the same cap on
+ # every read path (#58536/#58902/#59180 pattern).
+ app = web.Application(client_max_size=_FEISHU_WEBHOOK_MAX_BODY_BYTES)
+ app.router.add_post(self._webhook_path, self._handle_webhook_request)
+ self._webhook_runner = web.AppRunner(app)
+ await self._webhook_runner.setup()
+ self._webhook_site = web.TCPSite(self._webhook_runner, self._webhook_host, self._webhook_port)
+ await self._webhook_site.start()
+
+ def _build_lark_client(self, domain: Any) -> Any:
+ return (
+ lark.Client.builder()
+ .app_id(self._app_id)
+ .app_secret(self._app_secret)
+ .domain(domain)
+ .log_level(lark.LogLevel.WARNING)
+ .build()
+ )
+
+ async def _feishu_send_with_retry(
+ self,
+ *,
+ chat_id: str,
+ msg_type: str,
+ payload: str,
+ reply_to: Optional[str],
+ metadata: Optional[Dict[str, Any]],
+ ) -> Any:
+ last_error: Optional[Exception] = None
+ active_reply_to = reply_to
+ for attempt in range(_FEISHU_SEND_ATTEMPTS):
+ try:
+ response = await self._send_raw_message(
+ chat_id=chat_id,
+ msg_type=msg_type,
+ payload=payload,
+ reply_to=active_reply_to,
+ metadata=metadata,
+ )
+ # If replying to a message failed because it was withdrawn or not found,
+ # fall back to posting a new message directly to the chat.
+ if active_reply_to and not self._response_succeeded(response):
+ code = getattr(response, "code", None)
+ if code in _FEISHU_REPLY_FALLBACK_CODES:
+ if (metadata or {}).get("thread_id"):
+ logger.warning(
+ "[Feishu] Reply to %s failed in thread %s (code %s — message withdrawn/missing); "
+ "skipping top-level fallback to avoid creating a new topic",
+ active_reply_to,
+ (metadata or {}).get("thread_id"),
+ code,
+ )
+ return response
+ logger.warning(
+ "[Feishu] Reply to %s failed (code %s — message withdrawn/missing); "
+ "falling back to new message in chat %s",
+ active_reply_to,
+ code,
+ chat_id,
+ )
+ active_reply_to = None
+ response = await self._send_raw_message(
+ chat_id=chat_id,
+ msg_type=msg_type,
+ payload=payload,
+ reply_to=None,
+ metadata=metadata,
+ )
+ return response
+ except Exception as exc:
+ last_error = exc
+ if msg_type == "post" and _POST_CONTENT_INVALID_RE.search(str(exc)):
+ raise
+ if attempt >= _FEISHU_SEND_ATTEMPTS - 1:
+ raise
+ wait_seconds = 2 ** attempt
+ logger.warning(
+ "[Feishu] Send attempt %d/%d failed for chat %s; retrying in %ds: %s",
+ attempt + 1,
+ _FEISHU_SEND_ATTEMPTS,
+ chat_id,
+ wait_seconds,
+ exc,
+ )
+ await asyncio.sleep(wait_seconds)
+ raise last_error or RuntimeError("Feishu send failed")
+
+ async def _release_app_lock(self) -> None:
+ if not self._app_lock_identity:
+ return
+ try:
+ release_scoped_lock(_FEISHU_APP_LOCK_SCOPE, self._app_lock_identity)
+ except Exception as exc:
+ logger.warning("[Feishu] Failed to release app lock: %s", exc, exc_info=True)
+ finally:
+ self._app_lock_identity = None
+
+ # =========================================================================
+ # Lark API request builders
+ # =========================================================================
+
+ @staticmethod
+ def _build_get_chat_request(chat_id: str) -> Any:
+ if "GetChatRequest" in globals():
+ return GetChatRequest.builder().chat_id(chat_id).build()
+ return SimpleNamespace(chat_id=chat_id)
+
+ @staticmethod
+ def _build_get_message_request(message_id: str) -> Any:
+ if "GetMessageRequest" in globals():
+ return GetMessageRequest.builder().message_id(message_id).build()
+ return SimpleNamespace(message_id=message_id)
+
+ @staticmethod
+ def _build_message_resource_request(*, message_id: str, file_key: str, resource_type: str) -> Any:
+ if "GetMessageResourceRequest" in globals():
+ return (
+ GetMessageResourceRequest.builder()
+ .message_id(message_id)
+ .file_key(file_key)
+ .type(resource_type)
+ .build()
+ )
+ return SimpleNamespace(message_id=message_id, file_key=file_key, type=resource_type)
+
+ @staticmethod
+ def _build_get_application_request(*, app_id: str, lang: str) -> Any:
+ if "GetApplicationRequest" in globals():
+ return (
+ GetApplicationRequest.builder()
+ .app_id(app_id)
+ .lang(lang)
+ .build()
+ )
+ return SimpleNamespace(app_id=app_id, lang=lang)
+
+ @staticmethod
+ def _build_reply_message_body(*, content: str, msg_type: str, reply_in_thread: bool, uuid_value: str) -> Any:
+ if "ReplyMessageRequestBody" in globals():
+ return (
+ ReplyMessageRequestBody.builder()
+ .content(content)
+ .msg_type(msg_type)
+ .reply_in_thread(reply_in_thread)
+ .uuid(uuid_value)
+ .build()
+ )
+ return SimpleNamespace(
+ content=content,
+ msg_type=msg_type,
+ reply_in_thread=reply_in_thread,
+ uuid=uuid_value,
+ )
+
+ @staticmethod
+ def _build_reply_message_request(message_id: str, request_body: Any) -> Any:
+ if "ReplyMessageRequest" in globals():
+ return (
+ ReplyMessageRequest.builder()
+ .message_id(message_id)
+ .request_body(request_body)
+ .build()
+ )
+ return SimpleNamespace(message_id=message_id, request_body=request_body)
+
+ @staticmethod
+ def _build_update_message_body(*, msg_type: str, content: str) -> Any:
+ if "UpdateMessageRequestBody" in globals():
+ return (
+ UpdateMessageRequestBody.builder()
+ .msg_type(msg_type)
+ .content(content)
+ .build()
+ )
+ return SimpleNamespace(msg_type=msg_type, content=content)
+
+ @staticmethod
+ def _build_update_message_request(message_id: str, request_body: Any) -> Any:
+ if "UpdateMessageRequest" in globals():
+ return (
+ UpdateMessageRequest.builder()
+ .message_id(message_id)
+ .request_body(request_body)
+ .build()
+ )
+ return SimpleNamespace(message_id=message_id, request_body=request_body)
+
+ @staticmethod
+ def _build_create_message_body(*, receive_id: str, msg_type: str, content: str, uuid_value: str) -> Any:
+ if "CreateMessageRequestBody" in globals():
+ return (
+ CreateMessageRequestBody.builder()
+ .receive_id(receive_id)
+ .msg_type(msg_type)
+ .content(content)
+ .uuid(uuid_value)
+ .build()
+ )
+ return SimpleNamespace(
+ receive_id=receive_id,
+ msg_type=msg_type,
+ content=content,
+ uuid=uuid_value,
+ )
+
+ @staticmethod
+ def _build_create_message_request(receive_id_type: str, request_body: Any) -> Any:
+ if "CreateMessageRequest" in globals():
+ return (
+ CreateMessageRequest.builder()
+ .receive_id_type(receive_id_type)
+ .request_body(request_body)
+ .build()
+ )
+ return SimpleNamespace(receive_id_type=receive_id_type, request_body=request_body)
+
+ @staticmethod
+ def _build_image_upload_body(*, image_type: str, image: Any) -> Any:
+ if "CreateImageRequestBody" in globals():
+ return (
+ CreateImageRequestBody.builder()
+ .image_type(image_type)
+ .image(image)
+ .build()
+ )
+ return SimpleNamespace(image_type=image_type, image=image)
+
+ @staticmethod
+ def _build_image_upload_request(request_body: Any) -> Any:
+ if "CreateImageRequest" in globals():
+ return CreateImageRequest.builder().request_body(request_body).build()
+ return SimpleNamespace(request_body=request_body)
+
+ @staticmethod
+ def _build_file_upload_body(*, file_type: str, file_name: str, file: Any) -> Any:
+ if "CreateFileRequestBody" in globals():
+ return (
+ CreateFileRequestBody.builder()
+ .file_type(file_type)
+ .file_name(file_name)
+ .file(file)
+ .build()
+ )
+ return SimpleNamespace(file_type=file_type, file_name=file_name, file=file)
+
+ @staticmethod
+ def _build_file_upload_request(request_body: Any) -> Any:
+ if "CreateFileRequest" in globals():
+ return CreateFileRequest.builder().request_body(request_body).build()
+ return SimpleNamespace(request_body=request_body)
+
+ def _build_post_payload(self, content: str) -> str:
+ return _build_markdown_post_payload(content)
+
+ def _build_media_post_payload(self, *, caption: str, media_tag: Dict[str, str]) -> str:
+ payload = json.loads(self._build_post_payload(caption))
+ content = payload.setdefault("zh_cn", {}).setdefault("content", [])
+ content.append([media_tag])
+ return json.dumps(payload, ensure_ascii=False)
+
+ @staticmethod
+ def _resolve_outbound_file_routing(
+ *,
+ file_path: str,
+ requested_message_type: str,
+ ) -> tuple[str, str]:
+ ext = Path(file_path).suffix.lower()
+
+ if ext in _FEISHU_OPUS_UPLOAD_EXTENSIONS:
+ return "opus", "audio"
+
+ if ext in _FEISHU_MEDIA_UPLOAD_EXTENSIONS:
+ return "mp4", "media"
+
+ if ext in _FEISHU_DOC_UPLOAD_TYPES:
+ return _FEISHU_DOC_UPLOAD_TYPES[ext], "file"
+
+ if requested_message_type == "file":
+ return _FEISHU_FILE_UPLOAD_TYPE, "file"
+
+ return _FEISHU_FILE_UPLOAD_TYPE, "file"
+
+
+# =============================================================================
+# QR scan-to-create onboarding
+#
+# Device-code flow: user scans a QR code with Feishu/Lark mobile app and the
+# platform creates a fully configured bot application automatically.
+# Called by `hermes gateway setup` via _setup_feishu() in hermes_cli/gateway.py.
+# =============================================================================
+
+
+def _accounts_base_url(domain: str) -> str:
+ return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"])
+
+
+def _onboard_open_base_url(domain: str) -> str:
+ return _ONBOARD_OPEN_URLS.get(domain, _ONBOARD_OPEN_URLS["feishu"])
+
+
+def _post_registration(base_url: str, body: Dict[str, str]) -> dict:
+ """POST form-encoded data to the registration endpoint, return parsed JSON.
+
+ The registration endpoint returns JSON even on 4xx (e.g. poll returns
+ authorization_pending as a 400). We always parse the body regardless of
+ HTTP status.
+ """
+ url = f"{base_url}{_REGISTRATION_PATH}"
+ data = urlencode(body).encode("utf-8")
+ req = Request(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
+ try:
+ with urlopen(req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
+ return json.loads(resp.read().decode("utf-8"))
+ except HTTPError as exc:
+ body_bytes = exc.read()
+ if body_bytes:
+ try:
+ return json.loads(body_bytes.decode("utf-8"))
+ except (ValueError, json.JSONDecodeError):
+ raise exc from None
+ raise
+
+
+def _init_registration(domain: str = "feishu") -> None:
+ """Verify the environment supports client_secret auth.
+
+ Raises RuntimeError if not supported.
+ """
+ base_url = _accounts_base_url(domain)
+ res = _post_registration(base_url, {"action": "init"})
+ methods = res.get("supported_auth_methods") or []
+ if "client_secret" not in methods:
+ raise RuntimeError(
+ f"Feishu / Lark registration environment does not support client_secret auth. "
+ f"Supported: {methods}"
+ )
+
+
+def _begin_registration(domain: str = "feishu") -> dict:
+ """Start the device-code flow. Returns device_code, qr_url, user_code, interval, expire_in."""
+ base_url = _accounts_base_url(domain)
+ res = _post_registration(base_url, {
+ "action": "begin",
+ "archetype": "PersonalAgent",
+ "auth_method": "client_secret",
+ "request_user_info": "open_id",
+ })
+ device_code = res.get("device_code")
+ if not device_code:
+ raise RuntimeError("Feishu / Lark registration did not return a device_code")
+ qr_url = res.get("verification_uri_complete", "")
+ if "?" in qr_url:
+ qr_url += "&from=hermes&tp=hermes"
+ else:
+ qr_url += "?from=hermes&tp=hermes"
+ return {
+ "device_code": device_code,
+ "qr_url": qr_url,
+ "user_code": res.get("user_code", ""),
+ "interval": res.get("interval") or 5,
+ "expire_in": res.get("expire_in") or 600,
+ }
+
+
+def _poll_registration(
+ *,
+ device_code: str,
+ interval: int,
+ expire_in: int,
+ domain: str = "feishu",
+) -> Optional[dict]:
+ """Poll until the user scans the QR code, or timeout/denial.
+
+ Returns dict with app_id, app_secret, domain, open_id on success.
+ Returns None on failure.
+ """
+ deadline = time.monotonic() + expire_in
+ current_domain = domain
+ domain_switched = False
+ poll_count = 0
+
+ while time.monotonic() < deadline:
+ base_url = _accounts_base_url(current_domain)
+ try:
+ res = _post_registration(base_url, {
+ "action": "poll",
+ "device_code": device_code,
+ "tp": "ob_app",
+ })
+ except (URLError, OSError, json.JSONDecodeError):
+ time.sleep(interval)
+ continue
+
+ poll_count += 1
+ if poll_count == 1:
+ print(" Fetching configuration results...", end="", flush=True)
+ elif poll_count % 6 == 0:
+ print(".", end="", flush=True)
+
+ # Domain auto-detection
+ user_info = res.get("user_info") or {}
+ tenant_brand = user_info.get("tenant_brand")
+ if tenant_brand == "lark" and not domain_switched:
+ current_domain = "lark"
+ domain_switched = True
+ # Fall through — server may return credentials in this same response.
+
+ # Success
+ if res.get("client_id") and res.get("client_secret"):
+ if poll_count > 0:
+ print() # newline after "Fetching configuration results..." dots
+ return {
+ "app_id": res["client_id"],
+ "app_secret": res["client_secret"],
+ "domain": current_domain,
+ "open_id": user_info.get("open_id"),
+ }
+
+ # Terminal errors
+ error = res.get("error", "")
+ if error in {"access_denied", "expired_token"}:
+ if poll_count > 0:
+ print()
+ logger.warning("[Feishu onboard] Registration %s", error)
+ return None
+
+ # authorization_pending or unknown — keep polling
+ time.sleep(interval)
+
+ if poll_count > 0:
+ print()
+ logger.warning("[Feishu onboard] Poll timed out after %ds", expire_in)
+ return None
+
+
+try:
+ import qrcode as _qrcode_mod
+except (ImportError, TypeError):
+ _qrcode_mod = None # type: ignore[assignment]
+
+
+def _render_qr(url: str) -> bool:
+ """Try to render a QR code in the terminal. Returns True if successful."""
+ if _qrcode_mod is None:
+ return False
+ try:
+ qr = _qrcode_mod.QRCode()
+ qr.add_data(url)
+ qr.make(fit=True)
+ qr.print_ascii(invert=True)
+ return True
+ except Exception:
+ return False
+
+
+def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
+ """Verify bot connectivity via /open-apis/bot/v3/info.
+
+ Uses lark_oapi SDK when available, falls back to raw HTTP otherwise.
+ Returns {"bot_name": ..., "bot_open_id": ...} on success, None on failure.
+
+ Note: ``bot_open_id`` here is the bot's app-scoped open_id — the same ID
+ that Feishu puts in @mention payloads. It is NOT the app_id.
+ """
+ if FEISHU_AVAILABLE:
+ return _probe_bot_sdk(app_id, app_secret, domain)
+ return _probe_bot_http(app_id, app_secret, domain)
+
+
+def _build_onboard_client(app_id: str, app_secret: str, domain: str) -> Any:
+ """Build a lark Client for the given credentials and domain."""
+ sdk_domain = LARK_DOMAIN if domain == "lark" else FEISHU_DOMAIN
+ return (
+ lark.Client.builder()
+ .app_id(app_id)
+ .app_secret(app_secret)
+ .domain(sdk_domain)
+ .log_level(lark.LogLevel.WARNING)
+ .build()
+ )
+
+
+def _parse_bot_response(data: dict) -> Optional[dict]:
+ # /bot/v3/info returns bot.app_name; legacy paths used bot_name — accept both.
+ if data.get("code") != 0:
+ return None
+ bot = data.get("bot") or data.get("data", {}).get("bot") or {}
+ return {
+ "bot_name": bot.get("app_name") or bot.get("bot_name"),
+ "bot_open_id": bot.get("open_id"),
+ }
+
+
+def _probe_bot_sdk(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
+ """Probe bot info using lark_oapi SDK."""
+ try:
+ client = _build_onboard_client(app_id, app_secret, domain)
+ req = (
+ BaseRequest.builder()
+ .http_method(HttpMethod.GET)
+ .uri("/open-apis/bot/v3/info")
+ .token_types({AccessTokenType.TENANT})
+ .build()
+ )
+ resp = client.request(req)
+ content = getattr(getattr(resp, "raw", None), "content", None)
+ if content is None:
+ return None
+ return _parse_bot_response(json.loads(content))
+ except Exception as exc:
+ logger.debug("[Feishu onboard] SDK probe failed: %s", exc)
+ return None
+
+
+def _probe_bot_http(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
+ """Fallback probe using raw HTTP (when lark_oapi is not installed)."""
+ base_url = _onboard_open_base_url(domain)
+ try:
+ token_data = json.dumps({"app_id": app_id, "app_secret": app_secret}).encode("utf-8")
+ token_req = Request(
+ f"{base_url}/open-apis/auth/v3/tenant_access_token/internal",
+ data=token_data,
+ headers={"Content-Type": "application/json"},
+ )
+ with urlopen(token_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
+ token_res = json.loads(resp.read().decode("utf-8"))
+
+ access_token = token_res.get("tenant_access_token")
+ if not access_token:
+ return None
+
+ bot_req = Request(
+ f"{base_url}/open-apis/bot/v3/info",
+ headers={
+ "Authorization": f"Bearer {access_token}",
+ "Content-Type": "application/json",
+ },
+ )
+ with urlopen(bot_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
+ bot_res = json.loads(resp.read().decode("utf-8"))
+
+ return _parse_bot_response(bot_res)
+ except (URLError, OSError, KeyError, json.JSONDecodeError) as exc:
+ logger.debug("[Feishu onboard] HTTP probe failed: %s", exc)
+ return None
+
+
+def qr_register(
+ *,
+ initial_domain: str = "feishu",
+ timeout_seconds: int = 600,
+) -> Optional[dict]:
+ """Run the Feishu / Lark scan-to-create QR registration flow.
+
+ Returns on success::
+
+ {
+ "app_id": str,
+ "app_secret": str,
+ "domain": "feishu" | "lark",
+ "open_id": str | None,
+ "bot_name": str | None,
+ "bot_open_id": str | None,
+ }
+
+ Returns None on expected failures (network, auth denied, timeout).
+ Unexpected errors (bugs, protocol regressions) propagate to the caller.
+ """
+ try:
+ return _qr_register_inner(initial_domain=initial_domain, timeout_seconds=timeout_seconds)
+ except (RuntimeError, URLError, OSError, json.JSONDecodeError) as exc:
+ logger.warning("[Feishu onboard] Registration failed: %s", exc)
+ return None
+
+
+def _qr_register_inner(
+ *,
+ initial_domain: str,
+ timeout_seconds: int,
+) -> Optional[dict]:
+ """Run init → begin → poll → probe. Raises on network/protocol errors."""
+ print(" Connecting to Feishu / Lark...", end="", flush=True)
+ _init_registration(initial_domain)
+ begin = _begin_registration(initial_domain)
+ print(" done.")
+
+ print()
+ qr_url = begin["qr_url"]
+ if _render_qr(qr_url):
+ print(f"\n Scan the QR code above, or open this URL directly:\n {qr_url}")
+ else:
+ print(f" Open this URL in Feishu / Lark on your phone:\n\n {qr_url}\n")
+ print(" Tip: pip install qrcode to display a scannable QR code here next time")
+ print()
+
+ result = _poll_registration(
+ device_code=begin["device_code"],
+ interval=begin["interval"],
+ expire_in=min(begin["expire_in"], timeout_seconds),
+ domain=initial_domain,
+ )
+ if not result:
+ return None
+
+ # Probe bot — best-effort, don't fail the registration
+ bot_info = probe_bot(result["app_id"], result["app_secret"], result["domain"])
+ if bot_info:
+ result["bot_name"] = bot_info.get("bot_name")
+ result["bot_open_id"] = bot_info.get("bot_open_id")
+ else:
+ result["bot_name"] = None
+ result["bot_open_id"] = None
+
+ return result
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Plugin migration glue (#41112 / #3823)
+#
+# Added when the Feishu adapter (+ its feishu_comment / feishu_comment_rules /
+# feishu_meeting_invite satellites) moved from gateway/platforms/ into this
+# bundled plugin. Mirrors the Discord (#24356) / Slack migrations: a
+# register(ctx) entry point plus hook implementations that replace the
+# per-platform core touchpoints (the Platform.FEISHU elif in gateway/run.py,
+# the feishu_cfg YAML→env block + _PLATFORM_CONNECTED_CHECKERS entry in
+# gateway/config.py, the _setup_feishu wizard + _PLATFORMS["feishu"] static
+# dict in hermes_cli/gateway.py, and the _send_feishu dispatch in
+# tools/send_message_tool.py).
+# ──────────────────────────────────────────────────────────────────────────
+
+_MIGRATION_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
+_MIGRATION_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
+_MIGRATION_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
+_MIGRATION_VOICE_EXTS = {".ogg", ".opus"}
+
+
+async def _standalone_send(
+ pconfig,
+ chat_id,
+ message,
+ *,
+ thread_id=None,
+ media_files=None,
+ force_document=False,
+):
+ """Out-of-process Feishu/Lark delivery via the adapter's send pipeline.
+
+ Implements the standalone_sender_fn contract so deliver=feishu cron jobs
+ succeed when cron runs separately from the gateway. Builds a transient
+ FeishuAdapter, hydrates its lark client, and sends text + native media
+ (images, video, voice, documents). Replaces the legacy _send_feishu helper.
+ """
+ if not FEISHU_AVAILABLE:
+ return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
+
+ media_files = media_files or []
+ try:
+ adapter = FeishuAdapter(pconfig)
+ domain_name = getattr(adapter, "_domain_name", "feishu")
+ domain = FEISHU_DOMAIN if domain_name != "lark" else LARK_DOMAIN
+ adapter._client = adapter._build_lark_client(domain)
+ metadata = {"thread_id": thread_id} if thread_id else None
+
+ last_result = None
+ if message.strip():
+ last_result = await adapter.send(chat_id, message, metadata=metadata)
+ if not last_result.success:
+ return {"error": f"Feishu send failed: {last_result.error}"}
+
+ for media_path, is_voice in media_files:
+ if not os.path.exists(media_path):
+ return {"error": f"Media file not found: {media_path}"}
+ ext = os.path.splitext(media_path)[1].lower()
+ if ext in _MIGRATION_IMAGE_EXTS:
+ last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)
+ elif ext in _MIGRATION_VIDEO_EXTS:
+ last_result = await adapter.send_video(chat_id, media_path, metadata=metadata)
+ elif ext in _MIGRATION_VOICE_EXTS and is_voice:
+ last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
+ elif ext in _MIGRATION_AUDIO_EXTS:
+ last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
+ else:
+ last_result = await adapter.send_document(chat_id, media_path, metadata=metadata)
+ if not last_result.success:
+ return {"error": f"Feishu media send failed: {last_result.error}"}
+
+ if last_result is None:
+ return {"error": "No deliverable text or media remained after processing MEDIA tags"}
+ return {
+ "success": True,
+ "platform": "feishu",
+ "chat_id": chat_id,
+ "message_id": last_result.message_id,
+ }
+ except Exception as e:
+ return {"error": f"Feishu send failed: {e}"}
+
+
+def interactive_setup() -> None:
+ """Interactive setup for Feishu / Lark — scan-to-create or manual creds.
+
+ Replaces the central _setup_feishu in hermes_cli/gateway.py and the static
+ _PLATFORMS["feishu"] dict. CLI helpers are lazy-imported.
+ """
+ from hermes_cli.config import get_env_value, save_env_value
+ from hermes_cli.setup import prompt_choice
+ from hermes_cli.cli_output import (
+ prompt,
+ prompt_yes_no,
+ print_header,
+ print_info,
+ print_success,
+ print_warning,
+ print_error,
+ )
+
+ print_header("Feishu / Lark")
+ existing_app_id = get_env_value("FEISHU_APP_ID")
+ existing_secret = get_env_value("FEISHU_APP_SECRET")
+ if existing_app_id and existing_secret:
+ print_success("Feishu / Lark is already configured.")
+ if not prompt_yes_no("Reconfigure Feishu / Lark?", False):
+ return
+
+ method_idx = prompt_choice(
+ "How would you like to set up Feishu / Lark?",
+ [
+ "Scan QR code to create a new bot automatically (recommended)",
+ "Enter existing App ID and App Secret manually",
+ ],
+ 0,
+ )
+
+ credentials = None
+ used_qr = False
+
+ if method_idx == 0:
+ try:
+ credentials = qr_register()
+ except KeyboardInterrupt:
+ print_warning("Feishu / Lark setup cancelled.")
+ return
+ except Exception as exc:
+ print_warning(f"QR registration failed: {exc}")
+ if credentials:
+ used_qr = True
+ else:
+ print_info("QR setup did not complete. Continuing with manual input.")
+
+ if not credentials:
+ print_info("Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)")
+ print_info("Create an app, enable the Bot capability, and copy the credentials.")
+ app_id = prompt("App ID", password=False)
+ if not app_id:
+ print_warning("Skipped — Feishu / Lark won't work without an App ID.")
+ return
+ app_secret = prompt("App Secret", password=True)
+ if not app_secret:
+ print_warning("Skipped — Feishu / Lark won't work without an App Secret.")
+ return
+ domain_idx = prompt_choice("Domain", ["feishu (China)", "lark (International)"], 0)
+ domain = "lark" if domain_idx == 1 else "feishu"
+
+ bot_name = None
+ try:
+ bot_info = probe_bot(app_id, app_secret, domain)
+ if bot_info:
+ bot_name = bot_info.get("bot_name")
+ print_success(f"Credentials verified — bot: {bot_name or 'unnamed'}")
+ else:
+ print_warning("Could not verify bot connection. Credentials saved anyway.")
+ except Exception as exc:
+ print_warning(f"Credential verification skipped: {exc}")
+
+ credentials = {
+ "app_id": app_id,
+ "app_secret": app_secret,
+ "domain": domain,
+ "open_id": None,
+ "bot_name": bot_name,
+ }
+
+ app_id = credentials["app_id"]
+ app_secret = credentials["app_secret"]
+ domain = credentials.get("domain", "feishu")
+ open_id = credentials.get("open_id")
+ bot_name = credentials.get("bot_name")
+
+ save_env_value("FEISHU_APP_ID", app_id)
+ save_env_value("FEISHU_APP_SECRET", app_secret)
+ save_env_value("FEISHU_DOMAIN", domain)
+
+ if used_qr:
+ connection_mode = "websocket"
+ else:
+ mode_idx = prompt_choice(
+ "Connection mode",
+ [
+ "WebSocket (recommended — no public URL needed)",
+ "Webhook (requires a reachable HTTP endpoint)",
+ ],
+ 0,
+ )
+ connection_mode = "webhook" if mode_idx == 1 else "websocket"
+ if connection_mode == "webhook":
+ print_info("Webhook defaults: 127.0.0.1:8765/feishu/webhook")
+ print_info("Override with FEISHU_WEBHOOK_HOST / FEISHU_WEBHOOK_PORT / FEISHU_WEBHOOK_PATH")
+ print_info("For signature verification, set FEISHU_ENCRYPT_KEY and FEISHU_VERIFICATION_TOKEN")
+ save_env_value("FEISHU_CONNECTION_MODE", connection_mode)
+
+ if bot_name:
+ print_success(f"Bot created: {bot_name}")
+
+ access_idx = prompt_choice(
+ "How should direct messages be authorized?",
+ [
+ "Use DM pairing approval (recommended)",
+ "Allow all direct messages",
+ "Only allow listed user IDs",
+ ],
+ 0,
+ )
+ if access_idx == 0:
+ save_env_value("FEISHU_ALLOW_ALL_USERS", "false")
+ save_env_value("FEISHU_ALLOWED_USERS", "")
+ print_success("DM pairing enabled.")
+ print_info("Unknown users can request access; approve with `hermes pairing approve`.")
+ elif access_idx == 1:
+ save_env_value("FEISHU_ALLOW_ALL_USERS", "true")
+ save_env_value("FEISHU_ALLOWED_USERS", "")
+ print_warning("Open DM access enabled for Feishu / Lark.")
+ else:
+ save_env_value("FEISHU_ALLOW_ALL_USERS", "false")
+ default_allow = open_id or ""
+ allowlist = prompt(
+ "Allowed user IDs (comma-separated)", default_allow, password=False
+ ).replace(" ", "")
+ save_env_value("FEISHU_ALLOWED_USERS", allowlist)
+ print_success("Allowlist saved.")
+
+ group_idx = prompt_choice(
+ "How should group chats be handled?",
+ [
+ "Respond only when @mentioned in groups (recommended)",
+ "Disable group chats",
+ ],
+ 0,
+ )
+ if group_idx == 0:
+ save_env_value("FEISHU_GROUP_POLICY", "open")
+ print_info("Group chats enabled (bot must be @mentioned).")
+ else:
+ save_env_value("FEISHU_GROUP_POLICY", "disabled")
+ print_info("Group chats disabled.")
+
+ home_channel = prompt("Home chat ID (optional, for cron/notifications)", password=False)
+ if home_channel:
+ save_env_value("FEISHU_HOME_CHANNEL", home_channel)
+ print_success(f"Home channel set to {home_channel}")
+
+ print_success("🪽 Feishu / Lark configured!")
+ print_info(f"App ID: {app_id}")
+ print_info(f"Domain: {domain}")
+ if bot_name:
+ print_info(f"Bot: {bot_name}")
+
+
+def _apply_yaml_config(yaml_cfg: dict, feishu_cfg: dict) -> dict | None:
+ """Translate config.yaml feishu: keys into FEISHU_* env vars.
+
+ Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy
+ feishu_cfg block from gateway/config.py::load_gateway_config() (allow_bots).
+ Env vars take precedence over YAML. Returns None — flows through env.
+ """
+ if "allow_bots" in feishu_cfg and not os.getenv("FEISHU_ALLOW_BOTS"):
+ os.environ["FEISHU_ALLOW_BOTS"] = str(feishu_cfg["allow_bots"]).lower()
+ return None
+
+
+def _is_connected(config) -> bool:
+ """Feishu is connected when app_id is configured. Mirrors the legacy
+ _PLATFORM_CONNECTED_CHECKERS[Platform.FEISHU] = lambda cfg: bool(app_id)."""
+ extra = getattr(config, "extra", {}) or {}
+ return bool(extra.get("app_id"))
+
+
+def _build_adapter(config):
+ """Factory wrapper that constructs FeishuAdapter from a PlatformConfig."""
+ return FeishuAdapter(config)
+
+
+def register(ctx) -> None:
+ """Plugin entry point — called by the Hermes plugin system."""
+ ctx.register_platform(
+ name="feishu",
+ label="Feishu / Lark",
+ adapter_factory=_build_adapter,
+ check_fn=check_feishu_requirements,
+ is_connected=_is_connected,
+ validate_config=_is_connected,
+ required_env=["FEISHU_APP_ID", "FEISHU_APP_SECRET"],
+ install_hint="pip install 'hermes-agent[feishu]'",
+ setup_fn=interactive_setup,
+ apply_yaml_config_fn=_apply_yaml_config,
+ allowed_users_env="FEISHU_ALLOWED_USERS",
+ allow_all_env="FEISHU_ALLOW_ALL_USERS",
+ cron_deliver_env_var="FEISHU_HOME_CHANNEL",
+ standalone_sender_fn=_standalone_send,
+ max_message_length=8000,
+ emoji="🪽",
+ allow_update_command=True,
+ )