From 84620be08f3e8ec82ac15030e6d0f61bddb7f598 Mon Sep 17 00:00:00 2001 From: yuanchenglu Date: Tue, 26 May 2026 09:41:51 +0800 Subject: [PATCH 1/4] feat(gateway): add per-group default model override via group_model_defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a three-tier model resolution chain: global default < group default (by chat_id) < per-session /model override. Operators can now assign a different default model to each Feishu/Telegram/Discord group without affecting other groups or requiring users to type /model manually. Changes: - gateway/config.py: add group_model_defaults: Dict[str, str] field to GatewayConfig, with _normalize_group_model_defaults() helper and YAML pass-through in load_gateway_config() - gateway/run.py: check group_model_defaults[chat_id] in _resolve_session_agent_runtime() AFTER global model.default but BEFORE per-session /model overrides Usage in config.yaml: group_model_defaults: oc_869c1d931f317119d8f51f20797b599c: deepseek-v4-flash oc_53d22ce34646cebabf06e48ff986ee4c: mistral-large / 新增群默认模型覆盖功能,实现三层模型解析链。操作员可为每个群独立设置默认模型, / 而不影响其他群或要求用户手动 /model 切换。 --- gateway/config.py | 48 +++++++++++++++++++++++++++++++++++++++++++++++ gateway/run.py | 29 ++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) 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) From 9707f46d0d8fed5387c826a4c6c7e7927486961d Mon Sep 17 00:00:00 2001 From: yuanchenglu Date: Fri, 10 Jul 2026 23:49:46 +0800 Subject: [PATCH 2/4] fix(feishu): render Markdown tables natively via post+md instead of plain text downgrade --- plugins/platforms/feishu/adapter.py | 5665 +++++++++++++++++++++++++++ 1 file changed, 5665 insertions(+) create mode 100644 plugins/platforms/feishu/adapter.py diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py new file mode 100644 index 0000000000000..51a0c78336605 --- /dev/null +++ b/plugins/platforms/feishu/adapter.py @@ -0,0 +1,5665 @@ +""" +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]: + # Feishu post-type 'md' elements do not render markdown tables; sending + # table content as post causes the message to appear blank on the client. + # Force plain text for anything that looks like a markdown table. + 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, + ) From aad89bee09959f7cfa6c3f17151d09d7ef383d96 Mon Sep 17 00:00:00 2001 From: yuanchenglu Date: Fri, 10 Jul 2026 23:50:47 +0800 Subject: [PATCH 3/4] fix(feishu): render Markdown tables natively via post+md --- plugins/platforms/feishu/adapter.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 51a0c78336605..c0ed4c251344d 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -152,7 +152,7 @@ # --------------------------------------------------------------------------- _MARKDOWN_HINT_RE = re.compile( - r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(.+?)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)", + 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. @@ -4522,10 +4522,7 @@ def _is_duplicate(self, message_id: str) -> bool: # ========================================================================= def _build_outbound_payload(self, content: str) -> tuple[str, str]: - # Feishu post-type 'md' elements do not render markdown tables; sending - # table content as post causes the message to appear blank on the client. - # Force plain text for anything that looks like a markdown table. - if _MARKDOWN_HINT_RE.search(content): + 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) From 7dff877f1bf279fd3af5cdc2d9af23f71490121b Mon Sep 17 00:00:00 2001 From: yuanchenglu Date: Fri, 10 Jul 2026 23:51:41 +0800 Subject: [PATCH 4/4] fix(feishu): render Markdown tables via post+md natively --- plugins/platforms/feishu/adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index c0ed4c251344d..d59ef0fb10603 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -4522,7 +4522,7 @@ def _is_duplicate(self, message_id: str) -> bool: # ========================================================================= def _build_outbound_payload(self, content: str) -> tuple[str, str]: - if _MARKDOWN_HINT_RE.search(content): + 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)