Files
hermes-agent/plugins/platforms/feishu/adapter.py
686f6c61 11d5fb153d fix(feishu): delete truncated stream previews on fallback
Feishu had no delete_message, so a failed finalize-edit plus fallback
send left the truncated edit bubble next to the full final. Implement
the SDK delete and thread the fallback send to the originating message.

(cherry picked from commit c61add84ad40b1bc39288405a0a05b4f621e00fc)
2026-09-24 22:28:19 +05:30

4470 lines
219 KiB
Python

"""
Feishu/Lark platform adapter.
Supports WebSocket + Webhook transports, DM/@mention-gated group text, inbound
media caching, FEISHU_ALLOWED_USERS allowlisting, persistent dedup, per-chat
serial processing, processing-status reactions (Typing while working, CrossMark
on failure), reaction/card-button events as synthetic events, webhook anomaly
tracking and verification-token validation (all mirroring openclaw).
Feishu identity model (https://open.feishu.cn/document/home/user-identity-introduction/introduction):
open_id (ou_xxx) — app-scoped; differs per Feishu app; always in event payloads.
user_id (u_xxx) — tenant-scoped; needs ``contact:user.employee_id:readonly``; may be absent.
union_id (on_xxx) — developer-scoped; stable across one developer's apps.
Bot: ``app_id`` is the credential; the bot's own open_id (from ``/bot/v3/info``) is
what Feishu puts in ``mentions[].id.open_id`` — used for mention gating only.
Session keys prefer union_id (user_id_alt) over open_id (user_id) for stability.
"""
from __future__ import annotations
import asyncio
import collections
import concurrent.futures
import contextvars
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]
# lark_oapi is slow to import; the SDK names below stay None until Feishu actually connects
# (_load_lark_oapi binds every name in _LARK_SDK_IMPORTS plus ``lark`` and ``FeishuWSClient``).
_LARK_SDK_IMPORTS = (
("lark_oapi.api.application.v6", ("GetApplicationRequest",)),
("lark_oapi.api.im.v1", (
"CreateFileRequest", "CreateFileRequestBody", "CreateImageRequest", "CreateImageRequestBody",
"CreateMessageRequest", "CreateMessageRequestBody", "GetChatRequest", "GetMessageRequest",
"DeleteMessageRequest", "GetMessageResourceRequest", "P2ImMessageMessageReadV1",
"ReplyMessageRequest", "ReplyMessageRequestBody",
"UpdateMessageRequest", "UpdateMessageRequestBody",
)),
("lark_oapi.core", ("AccessTokenType", "HttpMethod")),
("lark_oapi.core.const", ("FEISHU_DOMAIN", "LARK_DOMAIN")),
("lark_oapi.core.model", ("BaseRequest",)),
("lark_oapi.event.callback.model.p2_card_action_trigger", ("CallBackCard", "P2CardActionTriggerResponse")),
("lark_oapi.event.dispatcher_handler", ("EventDispatcherHandler",)),
)
lark = FeishuWSClient = None # type: ignore[assignment]
globals().update({name: None for _, names in _LARK_SDK_IMPORTS for name in names})
FEISHU_AVAILABLE = False
_lark_import_lock = threading.Lock()
FEISHU_WEBSOCKET_AVAILABLE = websockets is not None
FEISHU_WEBHOOK_AVAILABLE = aiohttp is not None
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base_exec_approval import EA_HEADER_TEXT, EA_REASON_LABEL_TEXT
from gateway.platforms.base import (
BasePlatformAdapter, ExecApprovalPrompt, SendResult,
SUPPORTED_DOCUMENT_TYPES, cache_document_from_bytes_async, cache_image_from_url,
cache_audio_from_bytes_async, cache_image_from_bytes_async,
)
from gateway.platforms.event import MessageEvent, MessageType, ProcessingOutcome
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
from gateway.platforms._shared import (
apply_yaml_bridge as _apply_yaml_bridge, extra_or_secret as _shared_extra_or_secret,
get_scoped_secret as _get_scoped_secret, send_error
)
logger = logging.getLogger(__name__)
# --- Regex patterns ---
_MARKDOWN_HINT_RE = re.compile(
# Pipe table: any header line + separator line both starting with '|'.
r"(^\|.*\|\s*\n\|[-:|\s]+\|)"
# Headings, lists, code, bold/italic/strike/underline, links, blockquotes.
r"|(^#{1,6}\s)"
r"|(^\s*[-*]\s)"
r"|(^\s*\d+\.\s)"
r"|(^\s*---+\s*$)"
r"|(```)"
r"|(`[^`\n]+`)"
r"|(\*\*[^*\n].+?\*\*)"
r"|(~~[^~\n].+?~~)"
r"|(<u>.+?</u>)"
r"|(\*[^*\n]+\*)"
r"|(\[[^\]]+\]\([^)]+\))"
r"|(^>\s)",
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*$")
_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_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",
)
_RICH_BLOCK_TAGS = {
"plain_text", "lark_md", "markdown", "note", "div", "column_set", "column", "action", "button", "select_static",
"date_picker",
}
_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:
"""Every field is mirrored onto the adapter as ``self._<field>`` by ``_apply_settings``."""
app_id: str # credential identifier, never taken 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_open_id: str # app-scoped id from /bot/v3/info — what mentions[].id.open_id carries
bot_user_id: str # tenant-scoped fallback for mention matching
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
allow_all_dm: bool = False # resolved per-profile so multiplexed adapters honor their own .env
@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, k, None) for k in ("open_id", "user_id", "union_id")) 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", " ")
_TEXT_STYLE_WRAPPERS = (("bold", "**", "**"), ("italic", "*", "*"), ("underline", "<u>", "</u>"), ("strikethrough", "~~", "~~"))
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)
# Post text elements carry raw text plus separate style flags; the style wrappers below
# re-create the markdown. Escaping the text here put `\*\*bold\*\*` / `\`code\`` into the
# model's context and those backslashes came straight back out in replies (#9816).
rendered = text
if not rendered:
return ""
for key, prefix, suffix in _TEXT_STYLE_WRAPPERS: # order matters for nesting
if _is_style_enabled(style_dict, key):
rendered = f"{prefix}{rendered}{suffix}"
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:
"""Plain-text fallback: shared strip_markdown plus Feishu extras (blockquote, ~~, <u>, hr, CRLF)."""
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"<u>([\s\S]*?)</u>", r"\1", plain)
return strip_markdown(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, giving each fenced code block its own row.
Feishu's `md` renderer can swallow trailing content when a fence sits inside one
large element; splitting at real fence lines keeps surrounding prose visible.
"""
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
segment = "\n".join(current)
if segment.strip():
rows.append([{"tag": "md", "text": segment}])
current = []
for raw_line in content.splitlines():
fence_re = _MARKDOWN_FENCE_CLOSE_RE if in_code_block else _MARKDOWN_FENCE_OPEN_RE
is_fence = bool(fence_re.match(raw_line.strip()))
if is_fence and not in_code_block: # opening fence: prose before it becomes its own row
_flush_current()
current.append(raw_line)
if is_fence:
in_code_block = not in_code_block
if not in_code_block: # closing fence: the code block becomes its own row
_flush_current()
_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)
for entry in resolved.get("files", []) or []:
if (
not isinstance(entry, dict)
or _to_boolean(entry.get("is_folder"))
or not str(entry.get("file_key", "")).strip()
):
continue
placeholder = _render_post_element(
{**entry, "tag": "file"}, image_keys, media_refs, mentions_map,
)
if placeholder:
parts.append(placeholder)
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 {}
return _resolve_locale_payload(payload.get("post")) or _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 {}
# Preferred locales first, then any locale that carries a content list.
preferred = (payload.get(key) for key in _PREFERRED_LOCALES)
for candidate in map(_to_post_payload, itertools.chain(preferred, payload.values())):
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 {}
files = candidate.get("files")
return {
"title": str(candidate.get("title", "") or ""),
"content": content,
"files": files if isinstance(files, list) else [],
}
_STATIC_POST_TAGS = {"br": "\n", "hr": "\n\n---\n\n", "divider": "\n\n---\n\n"}
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 in _STATIC_POST_TAGS:
return _STATIC_POST_TAGS[tag]
if tag == "text":
return _render_text_element(element)
if tag in {"code_block", "pre"}:
return _render_code_block_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":
# <at>.user_id is a placeholder ("@_user_N" / "@_all"); mentions_map has the real ref.
placeholder = str(element.get("user_id", "")).strip()
if placeholder == "@_all":
# The SDK sometimes omits @_all from top-level mentions; record it so callers see it.
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)
display_name = (ref.name or ref.open_id or "user") if ref is not None else (
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()
names = (str(element.get(k, "")).strip() for k in ("file_name", "title", "text"))
file_name = next((n for n in names if n), "")
placeholder = f"[Attachment: {file_name}]" if file_name else "[Attachment]"
if not file_key:
return placeholder
if not any(ref.file_key == file_key for ref in media_refs):
media_refs.append(FeishuPostMediaRef(
file_key=file_key, file_name=file_name, resource_type=tag if tag in {"audio", "video"} else "file",
))
return placeholder
return ""
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 == "code":
code = str(element.get("text", "") or "") or str(element.get("content", "") or "")
return _wrap_inline_code(code) if code else ""
nested = (element.get(key) for key in ("text", "title", "content", "children", "elements"))
return _join_nested_posts(nested, image_keys, media_refs, mentions_map)
def _join_nested_posts(values: Any, image_keys: Any, media_refs: Any, mentions_map: Any) -> str:
parts = (_render_nested_post(item, image_keys, media_refs, mentions_map) for item in values)
return " ".join(part for part in 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_nested_posts(value, image_keys, media_refs, mentions_map)
if isinstance(value, dict):
direct = _render_post_element(value, image_keys, media_refs, mentions_map)
return direct or _join_nested_posts(value.values(), image_keys, media_refs, mentions_map)
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
# <at user_id="@_all">, 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)
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": _attachment_placeholder(media_ref.file_name)},
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_text_field(payload, "title", "summary", "preview", deep=("title", "summary", "preview", "description"))
entries = _collect_forward_entries(payload)
lines = ([title] if title else []) + entries[:8]
return FeishuNormalizedMessage(
raw_type="merge_forward", text_content="\n".join(lines).strip() or FALLBACK_FORWARD_TEXT,
relation_kind="merge_forward", metadata={"entry_count": len(entries), "title": title},
)
def _normalize_share_chat_message(payload: Dict[str, Any]) -> FeishuNormalizedMessage:
chat_name = _first_text_field(payload, "chat_name", "name", "title", deep=("chat_name", "name", "title"))
share_id = _first_text_field(payload, "chat_id", "open_chat_id", "share_chat_id")
lines = [f"Shared chat: {chat_name}" if chat_name else FALLBACK_SHARE_CHAT_TEXT]
if share_id:
lines.append(f"Chat ID: {share_id}")
return FeishuNormalizedMessage(
raw_type="share_chat", text_content="\n".join(lines), 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")),
)
actions = _collect_action_labels(card_payload)
lines = ([title] if title else []) + [line for line in _collect_card_lines(card_payload) if line != title]
if actions:
lines.append(f"Actions: {', '.join(actions)}")
return FeishuNormalizedMessage(
raw_type=message_type,
text_content="\n".join(lines[:12]).strip() or FALLBACK_INTERACTIVE_TEXT,
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_text_field(item, "sender_name", "user_name", "sender", "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_text_field(
item, "text", "summary", "preview", "content", deep=("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_text_field(item, "text", "name", "value", deep=("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):
return [seg for item in value for seg in _collect_text_segments(item, in_rich_block=in_rich_block)]
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 _RICH_BLOCK_TAGS
segments: List[str] = []
if next_in_rich_block:
for key in _SUPPORTED_CARD_TEXT_KEYS:
item = value.get(key)
if isinstance(item, str) and _normalize_feishu_text(item):
segments.append(_normalize_feishu_text(item))
for key, item in value.items():
if key not in _SKIP_TEXT_KEYS:
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_text_field(payload, "file_name", "title", "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:
"""First scalar (non-dict/list, non-None) value that normalizes to non-empty text."""
for value in values:
if value is None or isinstance(value, (dict, list)):
continue
normalized = _normalize_feishu_text(value if isinstance(value, str) else str(value))
if normalized:
return normalized
return ""
def _first_text_field(payload: Dict[str, Any], *keys: str, deep: tuple[str, ...] = ()) -> str:
"""``_first_non_empty_text`` over ``payload[key]`` for each key, then a deep ``_find_first_text``."""
values = [payload.get(key) for key in keys]
if deep:
values.append(_find_first_text(payload, keys=deep))
return _first_non_empty_text(*values)
# --- General text utilities ---
def _normalize_feishu_text(text: str, mentions_map: Optional[Dict[str, FeishuMentionRef]] = None) -> str:
def _sub(match: "re.Match[str]") -> str:
ref = (mentions_map or {}).get(match.group(0))
return " " if ref is None else f"@{ref.name or ref.open_id or 'user'}"
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]:
"""(open_id, user_id): message.get gives a string id + id_type; events give a nested UserId object."""
mention_id = getattr(mention, "id", None)
if isinstance(mention_id, str):
id_type = str(getattr(mention, "id_type", "") or "").lower()
return (mention_id, "") if id_type == "open_id" else ("", mention_id) if id_type == "user_id" else ("", "")
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()
is_self = bot.matches(open_id=open_id, user_id=user_id, name=name)
result[key] = FeishuMentionRef(name=name, open_id=open_id, is_self=is_self)
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 self-mentions are stripped unconditionally (word-boundary so @Al can't eat @Alice);
# trailing ones only when followed by whitespace/terminal punct so "don't @Bot again" survives.
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
# --- Multiplex isolation for the lark_oapi WebSocket client ---
#
# ``lark_oapi.ws.client`` keeps the asyncio loop in a *module-level global* (``loop``), and
# Hermes monkey-patches ``websockets.connect`` on the shared module to inject ping settings.
# In multiplex mode N profiles each run a WS client on their own thread, so they overwrite
# each other's globals (last-write-wins): tasks land on a sibling's loop ("Future attached
# to a different loop") or a client binds the wrong loop and goes deaf. Fix: install
# process-wide, thread-dispatching shims exactly once —
# * ``ws_client_module.loop`` becomes a proxy forwarding to the loop registered by the
# *current thread* (all SDK reads happen on the loop-owning thread); unregistered
# threads fall back to the SDK's original loop.
# * ``websockets.connect`` becomes one dispatcher that merges the calling thread's
# registered ping overrides, so profiles stop racing over the global patch.
# --------------------------------------------------------------------------- Multiplex isolation for the
# lark_oapi WebSocket client (#73779)
# --------------------------------------------------------------------------- ``lark_oapi.ws.client`` keeps
# the asyncio loop used by ``Client.start()`` and every coroutine it spawns in a *module-level global*
# (``loop``), and Hermes also monkey-patches ``websockets.connect`` on the shared ``websockets`` module to
# inject per-adapter ping settings. In multiplex mode every profile runs its own WS client on a dedicated
# thread, so the N threads overwrite each other's module globals (last-write-wins): a client ends up
# scheduling tasks on a sibling profile's loop ("Future attached to a different loop" crashes) or binds to
# the wrong loop at construction time and goes deaf from the start. The fix installs process-wide,
# thread-dispatching shims exactly once: * ``ws_client_module.loop`` becomes a proxy that forwards every
# attribute access to the loop registered by the *current thread*. All SDK reads of the global happen on the
# thread that owns the loop (``start()`` blocks in ``run_until_complete`` and every ``create_task`` callback
# runs on the loop's own thread), so each profile transparently sees its own loop. Threads that never
# registered one (single-profile installs, CLI) fall back to the SDK's original module loop. *
# ``websockets.connect`` becomes a single dispatcher that merges the per-thread ping overrides registered by
# the calling profile, so profiles no longer race over the global patch or restore each other's hooks while
# a sibling is still connected.
_WS_ISOLATION_LOCK = threading.Lock()
_WS_ISOLATION_INSTALLED = False
_ws_isolation_state = threading.local() # per WS thread: .loop and .connect_kwargs
class _ThreadLocalLoopProxy:
"""Forwards attribute access to the current thread's registered loop."""
def __init__(self, fallback: Any) -> None:
self._fallback = fallback
def _target(self) -> Any:
return getattr(_ws_isolation_state, "loop", None) or self._fallback
def __getattr__(self, name: str) -> Any:
return getattr(self._target(), name)
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<ThreadLocalLoopProxy target={self._target()!r}>"
def _install_lark_ws_isolation(ws_client_module: Any) -> None:
"""Install the thread-dispatching shims once per process (idempotent)."""
global _WS_ISOLATION_INSTALLED
with _WS_ISOLATION_LOCK:
if _WS_ISOLATION_INSTALLED:
return
ws_client_module.loop = _ThreadLocalLoopProxy(ws_client_module.loop)
real_connect = ws_client_module.websockets.connect
def _dispatch_connect(*args: Any, **kwargs: Any) -> Any:
overrides = getattr(_ws_isolation_state, "connect_kwargs", None) or {}
for key, value in overrides.items():
kwargs.setdefault(key, value)
return real_connect(*args, **kwargs)
# Keep inspect.signature(websockets.connect) honest — the SDK probes it for ``proxy`` support.
_dispatch_connect.__wrapped__ = real_connect
_dispatch_connect.__name__ = getattr(real_connect, "__name__", "connect")
ws_client_module.websockets.connect = _dispatch_connect
original_receive_loop = ws_client_module.Client._receive_message_loop
async def _receive_message_loop_exit_notify(self: Any) -> None:
# The SDK schedules this coroutine right after the websocket handshake succeeded, so its
# entry is the only in-thread proof that a (re)built link is actually up.
on_link_up = getattr(_ws_isolation_state, "on_link_up", None)
if on_link_up is not None:
on_link_up()
try:
await original_receive_loop(self)
except Exception:
# ``Client.start()`` parks in ``run_until_complete(_select())``, which only returns
# when this worker loop stops, and the receive loop runs as a bare ``create_task``
# whose exception nobody retrieves — so every unrecoverable exit (reconnect ladder
# disabled, or its ``ClientException``/``ServerUnreachableException`` re-raise)
# left a deaf-but-ESTABLISHED socket whose executor future never completed and the
# supervisor never rebuilt (#113662). Log the root cause here and stop the loop so
# ``start()`` raises, the future completes and ``_supervise_websocket_thread`` fires.
# A *normal* return means the SDK's own ladder already reconnected (it scheduled a
# fresh receive loop) and must NOT stop the loop. Deliberate disconnects nil
# ``_ws_client`` first, so the supervisor exits without restarting.
adapter = getattr(_ws_isolation_state, "adapter", None)
if adapter is None or getattr(adapter, "_running", True):
logger.exception(
"[Feishu] lark WS receive loop died; stopping the worker "
"loop so the supervisor can rebuild"
)
else:
# ``disconnect()`` sent the CLOSE frame itself: the loop ending here is expected.
logger.debug("[Feishu] lark WS receive loop ended during disconnect", exc_info=True)
asyncio.get_running_loop().stop()
ws_client_module.Client._receive_message_loop = _receive_message_loop_exit_notify
_WS_ISOLATION_INSTALLED = True
def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None:
"""Run the official Lark WS client in its own thread-local loop (see isolation notes above)."""
import lark_oapi.ws.client as ws_client_module
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
adapter._ws_thread_loop = loop
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)
# SDK observer (lark-oapi ``Client.on_reconnecting``, fired first thing in ``_reconnect()``):
# on the live link ``_auto_reconnect`` is on, so the ladder runs *inside* the receive loop
# and the thread never dies — without this the supervisor's ``retrying`` is never published.
setattr(ws_client, "on_reconnecting", _on_reconnecting)
except Exception:
logger.debug("[Feishu] Failed to apply websocket runtime overrides", exc_info=True)
connect_overrides = {
key: value
for key, value in (("ping_interval", adapter._ws_ping_interval), ("ping_timeout", adapter._ws_ping_timeout))
if value is not None
}
adapter_loop = adapter._loop
def _on_reconnecting() -> None:
if adapter_loop is not None and not adapter_loop.is_closed():
adapter_loop.call_soon_threadsafe(adapter._ws_link_retrying, ws_client)
def _on_link_up() -> None:
# Fired on the WS thread when the SDK scheduled a receive loop (handshake done); hop to the
# adapter loop so the ``connected`` re-stamp after a supervisor rebuild runs where the adapter's
# state lives.
if adapter_loop is not None and not adapter_loop.is_closed():
adapter_loop.call_soon_threadsafe(adapter._ws_link_up, ws_client)
_install_lark_ws_isolation(ws_client_module)
_ws_isolation_state.loop = loop
_ws_isolation_state.connect_kwargs = connect_overrides
_ws_isolation_state.on_link_up = _on_link_up
_ws_isolation_state.adapter = adapter
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
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_isolation_state.loop = None
_ws_isolation_state.connect_kwargs = None
_ws_isolation_state.on_link_up = None
_ws_isolation_state.adapter = None
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))
for closer in (loop.stop, loop.close):
try:
closer()
except Exception:
pass
adapter._ws_thread_loop = None
def _load_lark_oapi() -> bool:
"""Import and bind the Feishu SDK after an explicit connection request."""
if FEISHU_AVAILABLE:
return True
with _lark_import_lock:
if FEISHU_AVAILABLE:
return True
import importlib
try:
bound: Dict[str, Any] = {"lark": importlib.import_module("lark_oapi")}
for module_name, names in _LARK_SDK_IMPORTS:
module = importlib.import_module(module_name)
bound.update({name: getattr(module, name) for name in names})
bound["FeishuWSClient"] = importlib.import_module("lark_oapi.ws").Client
except (ImportError, AttributeError):
return False
bound["FEISHU_AVAILABLE"] = True
globals().update(bound)
return True
def feishu_deps_present() -> bool:
"""PASSIVE registry ``check_fn``: is lark-oapi installed? Must never install or import the SDK.
Uses cheap importlib.metadata lookups; the real import is deferred to ``_load_lark_oapi``
and the ACTIVE installer is ``check_feishu_requirements`` (``ensure_deps_fn``).
Registry ``check_fn`` — called from status displays and config loading, so it must never install
anything. The ACTIVE lazy-installer (``check_feishu_requirements``) is registered as ``ensure_deps_fn``
and runs from ``create_adapter()`` when this returns False (#79812).
"""
if FEISHU_AVAILABLE:
return True
try:
from tools.lazy_deps import is_available
return is_available("platform.feishu")
except Exception: # pragma: no cover — defensive
return False
def check_feishu_requirements() -> bool:
"""Ensure Feishu dependencies are installed without importing the SDK."""
if FEISHU_AVAILABLE:
return True
from tools.lazy_deps import ensure
try:
ensure("platform.feishu", prompt=False)
return True
except Exception:
return False
def _tenant_get_request(uri: str, *, queries: Optional[List[tuple[str, str]]] = None) -> Any:
"""Raw ``BaseRequest`` GET with the tenant access token (bot/v3 endpoints have no typed SDK request)."""
builder = BaseRequest.builder().http_method(HttpMethod.GET).uri(uri)
if queries is not None:
builder = builder.queries(queries)
return builder.token_types({AccessTokenType.TENANT}).build()
def _sdk_domain(domain_name: str) -> Any:
return LARK_DOMAIN if domain_name == "lark" else FEISHU_DOMAIN
def _build_lark_client(app_id: str, app_secret: str, sdk_domain: Any) -> Any:
return lark.Client.builder().app_id(app_id).app_secret(app_secret).domain(sdk_domain).log_level(lark.LogLevel.WARNING).build()
def _card_button(label: str, btn_type: str, value: Dict[str, Any]) -> Dict[str, Any]:
return {"tag": "button", "text": {"tag": "plain_text", "content": label}, "type": btn_type, "value": value}
def _card(title: str, template: str, markdown: str, *, actions: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
"""Wide interactive card: colored header + one markdown block (+ an optional button row)."""
elements: List[Dict[str, Any]] = [{"tag": "markdown", "content": markdown}]
if actions is not None:
elements.append({"tag": "action", "actions": actions})
return {
"config": {"wide_screen_mode": True},
"header": {"title": {"content": title, "tag": "plain_text"}, "template": template},
"elements": elements,
}
def _sdk_build(request_cls: Any, **fields: Any) -> Any:
"""``request_cls.builder().<field>(value)...build()``; SimpleNamespace when the SDK is unbound."""
if request_cls is None:
return SimpleNamespace(**fields)
builder = request_cls.builder()
for name, value in fields.items():
builder = getattr(builder, name)(value)
return builder.build()
class FeishuAdapter(BasePlatformAdapter):
"""Feishu/Lark bot adapter."""
# Answers /p/<profile>/... on the default listener for a served secondary (shared_ingress).
serves_profile_prefix: bool = True
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
CHAT_LOCK_MAX_SIZE: int = 1000 # distinct chat IDs kept in _chat_locks before LRU eviction
_SPLIT_THRESHOLD = 4000 # chunk near Feishu's ~4096-char client split → continuation almost certain
# --- 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 pool for blocking SDK calls, recreated on demand: a torn-down default
# executor can no longer wedge sends with "Executor shutdown has been called".
# See issue #10849.
self._sdk_executor_lock = threading.Lock()
self._sdk_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
self._sdk_executor_closing = False # set on disconnect so a real teardown isn't resurrected
self._ws_client = self._ws_future = self._ws_supervisor = self._ws_thread_loop = None
self._ws_restart_backoff = 5.0
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._webhook_runner = self._webhook_site = self._event_handler = 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()
# Serializes the offloaded dedup-state flushes so two concurrent
# inbound messages cannot land their writes out of order.
self._dedup_persist_lock = asyncio.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 loop was ready; one drainer thread replays them.
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._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
# Button-card state: id → {session_key, message_id, chat_id}
self._approval_state: Dict[int, Dict[str, str]] = {}
self._approval_counter = itertools.count(1)
self._update_prompt_state: Dict[int, Dict[str, str]] = {}
self._update_prompt_counter = itertools.count(1)
# Reaction deletion needs the opaque reaction_id from create, cached 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:
def _id_set(values: Any) -> set[str]:
return {str(u).strip() for u in values if str(u).strip()}
def _secret(name: str, default: str = "") -> str:
return _get_scoped_secret(name, default).strip()
def _extra_or_secret(key: str, env: str, default: str = "") -> str:
return str(_shared_extra_or_secret(extra, key, env, default)).strip()
_extra_or_env = _extra_or_secret
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
group_rules[str(chat_id)] = FeishuGroupRule(
policy=str(rule_cfg.get("policy", "open")).strip().lower(),
allowlist=_id_set(rule_cfg.get("allowlist", [])),
blacklist=_id_set(rule_cfg.get("blacklist", [])),
# Only override when explicitly set — missing vs false must not collapse.
require_mention=_to_boolean(rule_cfg["require_mention"]) if "require_mention" in rule_cfg else None,
)
# Scoped read: under multiplex a secondary profile's .env must govern its own adapter; yaml
# feishu.allow_bots reaches it via ``extra`` (the env bridge is skipped under its scope).
# See #86905.
allow_bots = str(_extra_or_secret("allow_bots", "FEISHU_ALLOW_BOTS", "none") or "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"
allow_all_dm = any(
_secret(var).lower() in {"true", "1", "yes"} for var in ("FEISHU_ALLOW_ALL_USERS", "GATEWAY_ALLOW_ALL_USERS")
)
return FeishuAdapterSettings(
app_id=_extra_or_secret("app_id", "FEISHU_APP_ID"),
app_secret=_extra_or_secret("app_secret", "FEISHU_APP_SECRET"),
domain_name=_extra_or_env("domain", "FEISHU_DOMAIN", "feishu").lower(),
connection_mode=_extra_or_env("connection_mode", "FEISHU_CONNECTION_MODE", "websocket").lower(),
encrypt_key=_extra_or_secret("encrypt_key", "FEISHU_ENCRYPT_KEY"),
verification_token=_extra_or_secret("verification_token", "FEISHU_VERIFICATION_TOKEN"),
group_policy=_secret("FEISHU_GROUP_POLICY", "allowlist").lower(),
allowed_group_users=frozenset(_id_set(_get_scoped_secret("FEISHU_ALLOWED_USERS", "").split(","))),
bot_open_id=_secret("FEISHU_BOT_OPEN_ID"),
bot_user_id=_secret("FEISHU_BOT_USER_ID"),
bot_name=_secret("FEISHU_BOT_NAME"),
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=_extra_or_env("webhook_host", "FEISHU_WEBHOOK_HOST", _DEFAULT_WEBHOOK_HOST),
webhook_port=int(extra.get("webhook_port") or _get_scoped_secret("FEISHU_WEBHOOK_PORT", str(_DEFAULT_WEBHOOK_PORT))),
webhook_path=_extra_or_env("webhook_path", "FEISHU_WEBHOOK_PATH", _DEFAULT_WEBHOOK_PATH) 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=frozenset(_id_set(extra.get("admins", []))),
default_group_policy=str(extra.get("default_group_policy", "")).strip().lower(),
group_rules=group_rules, allow_bots=allow_bots, allow_all_dm=allow_all_dm,
require_mention=_to_boolean(extra.get("require_mention", _get_scoped_secret("FEISHU_REQUIRE_MENTION", "true"))),
)
def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
# Every settings field is mirrored as ``self._<field>``; the three below need coercion.
for name in FeishuAdapterSettings.__dataclass_fields__:
setattr(self, f"_{name}", getattr(settings, name))
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
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 d: self._on_reaction_event("im.message.reaction.created_v1", d))
.register_p2_im_message_reaction_deleted_v1(lambda d: self._on_reaction_event("im.message.reaction.deleted_v1", d))
.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:
"""Adapter-owned executor; recreated after an *external* shutdown, never after our own close.
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) # bare adapters (tests) may lack __init__ state
if lock is None:
lock = self._sdk_executor_lock = threading.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.
``copy_context().run`` mirrors ``asyncio.to_thread``: the worker sees the caller's
profile HERMES_HOME override / secret scope (multiplexed dedup flush, thread lookup).
"""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
self._get_sdk_executor(), contextvars.copy_context().run, 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."""
self._sdk_executor_closing = False # re-arm the SDK executor after a prior disconnect
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
if not await asyncio.to_thread(_load_lark_oapi):
logger.error("[Feishu] lark-oapi not installed")
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()
if self._connection_mode == "websocket":
# The WS thread can die without any external signal; keep a watcher alive.
self._ws_supervisor = asyncio.ensure_future(self._supervise_websocket_thread())
self._mark_connected()
logger.info("[Feishu] Connected in %s mode (%s)", self._connection_mode, self._domain_name)
# Plugin-registered native handlers (lark_oapi client).
self._wire_plugin_handlers(self._client)
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
if self._ws_supervisor is not None:
self._ws_supervisor.cancel()
self._ws_supervisor = None
await self._cancel_pending_tasks(self._pending_text_batch_tasks)
await self._cancel_pending_tasks(self._pending_media_batch_tasks)
self._reset_batch_buffers()
# ``_disable_websocket_auto_reconnect()`` nils ``_ws_client`` — capture first.
# 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.
ws_client = self._ws_client
ws_thread_loop = self._ws_thread_loop
self._disable_websocket_auto_reconnect()
await self._stop_webhook_server()
await self._teardown_ws_thread(ws_client, ws_thread_loop)
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 _teardown_ws_thread(self, ws_client: Any, ws_thread_loop: Any) -> None:
"""CLOSE frame → cancel the WS thread's tasks → wait for the thread future."""
# Send the CLOSE frame BEFORE tearing down the thread loop; otherwise Feishu keeps
# routing to the stale endpoint until server-side CLOSE-WAIT expires (minutes to hours).
loop_alive = ws_thread_loop is not None and not ws_thread_loop.is_closed()
if ws_client is not None and loop_alive and hasattr(ws_client, "_disconnect"):
try:
future = asyncio.run_coroutine_threadsafe(ws_client._disconnect(), ws_thread_loop)
# A CLOSE frame is one control frame; if 5s isn't enough the link is already wedged.
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 loop_alive:
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)
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)
# Decide markdown-vs-text once for the whole message: a chunk of a long
# markdown reply may be plain prose that fails the per-chunk regex and would
# otherwise render as literal ``**bold`` / fences while other chunks render.
# Lock the markdown decision at the whole-message level so every chunk consistently uses ``post``.
# See #26841.
prefer_post = bool(_MARKDOWN_HINT_RE.search(formatted))
last_response = None
async def _send_plain(chunk: str) -> Any:
return 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,
)
try:
for chunk in chunks:
msg_type, payload = self._build_outbound_payload(chunk, prefer_post=prefer_post)
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 _send_plain(chunk)
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 _send_plain(chunk)
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)
async def _update(msg_type: str, payload: str) -> SendResult:
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)
return self._finalize_send_result(response, "update failed")
try:
msg_type, payload = self._build_outbound_payload(content)
result = await _update(msg_type, payload)
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")
result = await _update(
"text", json.dumps({"text": _strip_markdown_to_plain_text(content)}, ensure_ascii=False),
)
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 delete_message(self, chat_id: str, message_id: str) -> bool:
"""Delete a bot-posted message so stream-consumer fallback/fresh-final can
remove a truncated edit bubble instead of leaving it next to the full send.
Feishu has no ``delete_message`` today, so ``_delete_previews`` no-ops and a
failed finalize-edit + fallback send (#103068) keeps both bubbles.
"""
if not self._client or not message_id:
return False
try:
request = self._build_delete_message_request(message_id)
response = await self._run_blocking(self._client.im.v1.message.delete, request)
return self._response_succeeded(response)
except Exception:
logger.debug("[Feishu] Failed to delete message %s", message_id, exc_info=True)
return False
# Template attrs for the shared _format_exec_approval core. The card
# header carries the title, so the text core starts at the code fence.
_EA_HEADER = ""
_EA_REASON_LABEL = f"**{EA_REASON_LABEL_TEXT}:** "
_EA_SMART_DENY_LINE = "\n\n**Smart DENY:** owner override applies to this one operation only."
_EA_CMD_BUDGET = 3000
_EA_ACTION_LABELS = {"once": "✅ Allow Once", "session": "✅ Session", "always": "✅ Always", "deny": "❌ Deny"}
_EA_CARD_ACTIONS = {"once": "approve_once", "session": "approve_session", "always": "approve_always", "deny": "deny"}
async def _send_exec_approval_prompt(self, prompt: ExecApprovalPrompt) -> SendResult:
"""Approval-button card; ``hermes_action`` in each button value lets the click callback
route to ``resolve_gateway_approval()`` and unblock the waiting agent thread."""
if not self._client:
return SendResult(success=False, error="Not connected")
try:
approval_id = next(self._approval_counter)
actions = [
_card_button(label, style or "default",
{"hermes_action": self._EA_CARD_ACTIONS[choice], "approval_id": approval_id})
for label, choice, style in prompt.actions]
card = _card(f"⚠️ {EA_HEADER_TEXT}", "orange", prompt.text, actions=actions)
return await self._send_interactive_card(
prompt.chat_id, card, prompt.metadata, "send_exec_approval failed",
state_map=self._approval_state, state_id=approval_id, session_key=prompt.session_key,
)
except Exception as exc:
logger.warning("[Feishu] send_exec_approval failed: %s", exc)
return SendResult(success=False, error=str(exc))
async def _send_interactive_card(
self, chat_id: str, card: Dict[str, Any], metadata: Optional[Dict[str, Any]], failure_message: str, *,
state_map: Dict[int, Dict[str, str]], state_id: int, session_key: str,
) -> SendResult:
"""Send a button card and, on success, remember where it went so a click can be validated."""
response = await self._feishu_send_with_retry(
chat_id=chat_id, msg_type="interactive", payload=json.dumps(card, ensure_ascii=False),
reply_to=None, metadata=metadata,
)
result = self._finalize_send_result(response, failure_message)
if result.success:
state_map[state_id] = {
"session_key": session_key,
"message_id": result.message_id or "",
"chat_id": chat_id,
}
return result
@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 _card_button(label, btn_type, {"hermes_update_prompt_action": answer, "update_prompt_id": prompt_id})
actions = [_btn("✓ Yes", "y", "primary"), _btn("✗ No", "n", "danger")]
return _card("☤ Update Needs Your Input", "orange", f"{prompt}{default_hint}", actions=actions)
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)
card = self._build_update_prompt_card(prompt=prompt, default=default, prompt_id=prompt_id)
return await self._send_interactive_card(
chat_id, card, metadata, "send_update_prompt failed",
state_map=self._update_prompt_state, state_id=prompt_id, session_key=session_key,
)
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]:
"""Raw card JSON shown in place of the buttons once an approval is resolved."""
icon = "❌" if choice == "deny" else "✅"
label = _APPROVAL_LABEL_MAP.get(choice, "Resolved")
return _card(f"{icon} {label}", "red" if choice == "deny" else "green", f"{icon} **{label}** by {user_name}")
@staticmethod
def _build_resolved_update_prompt_card(*, answer: str, user_name: str) -> Dict[str, Any]:
yes = answer == "y"
title = f"{'✅' if yes else '❌'} Update prompt answered: {'Yes' if yes else 'No'}"
return _card(title, "green" if yes else "red", 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, encoding="utf-8")
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:
"""Native voice message (Feishu only accepts Opus): non-opus audio is transcoded via ffmpeg
first; without ffmpeg the original file goes out as a file attachment."""
transcoded_path: Optional[str] = None
ext = Path(audio_path).suffix.lower()
if ext not in _FEISHU_OPUS_UPLOAD_EXTENSIONS:
from gateway.platforms.base import transcode_to_ogg_opus
transcoded_path = await asyncio.to_thread(transcode_to_ogg_opus, audio_path)
if transcoded_path:
audio_path = transcoded_path
try:
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",
)
finally:
if transcoded_path:
try:
os.unlink(transcoded_path)
except OSError:
pass
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_file = _io.BytesIO(f.read()) # lark's MultipartEncoder needs .name and .tell()
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",
)
message_response = await self._send_uploaded_key(
chat_id=chat_id, reply_to=reply_to, metadata=metadata, caption=caption,
key_msg_type="image", key_payload={"image_key": image_key},
media_tag={"tag": "img", "image_key": image_key},
)
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 = self.warning_text(
f"[GIF downgraded to file]\n{caption}" if caption else "[GIF downgraded to file]", caption)
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:
"""SDK dispatcher callback (background thread); queues for replay while the loop isn't ready."""
loop = self._loop
if not self._loop_accepts_callbacks(loop):
if self._enqueue_pending_inbound_event(data):
# Replayed events hop onto the loop from THIS thread's context; keep the WS thread's
# profile scope (see _connect_websocket) rather than starting from an empty one.
threading.Thread(
target=contextvars.copy_context().run, args=(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:
"""Queue an event for replay; True when the caller should spawn the (single) drainer thread."""
with self._pending_inbound_lock:
if len(self._pending_inbound_events) >= self._pending_inbound_max_depth:
# Full — drop the oldest (loop unavailable for long AND WS still firing).
dropped = self._pending_inbound_events.pop(0)
try:
message = getattr(getattr(dropped, "event", None), "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
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:
"""Daemon-thread drainer: replay queued inbound events once the loop is ready (or drop on shutdown/timeout)."""
poll_interval = 0.25
max_wait_seconds = 120.0 # safety cap: drop queue after 2 minutes
waited = 0.0
def _take_all() -> List[Any]:
with self._pending_inbound_lock:
batch = self._pending_inbound_events[:]
self._pending_inbound_events.clear()
return batch
def _queue_empty() -> bool:
with self._pending_inbound_lock:
return not self._pending_inbound_events
try:
while True:
if not getattr(self, "_running", True):
# Shutting down — drop rather than hold events against a closed loop.
dropped = len(_take_all())
if dropped:
logger.warning("[Feishu] Dropped %d queued inbound event(s) during shutdown", dropped)
return
loop = self._loop
if self._loop_accepts_callbacks(loop):
batch = _take_all()
if not batch:
if _queue_empty(): # emptied between check and grab
return
continue
# Loop closed/unavailable mid-batch → requeue those and poll again.
requeue = [e for e in batch if not self._submit_on_loop(loop, self._handle_message_event_data(e))]
if requeue:
with self._pending_inbound_lock:
self._pending_inbound_events[:0] = requeue
if len(batch) - len(requeue):
logger.info("[Feishu] Replayed %d queued inbound event(s)", len(batch) - len(requeue))
if not requeue and _queue_empty(): # fully drained and nothing new arrived
return
continue
if waited >= max_wait_seconds:
logger.error(
"[Feishu] Adapter loop unavailable for %.0fs; dropped %d queued inbound event(s)",
max_wait_seconds, len(_take_all()),
)
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 await 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)
if reason == "group_policy_rejected":
self._warn_once_empty_allowlist_deny(getattr(message, "chat_id", "") or "")
return
await self._process_inbound_message(
data=data, message=message, sender_id=getattr(sender, "sender_id", None),
chat_type=getattr(message, "chat_type", "p2p"), 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."""
message = getattr(getattr(data, "event", None), "message", None)
logger.debug("[Feishu] Ignoring message_read event: %s", getattr(message, "message_id", None) or "")
def _on_bot_membership_change(self, data: Any, verb: str) -> None:
chat_id = str(getattr(getattr(data, "event", None), "chat_id", "") or "")
logger.info("[Feishu] Bot %s chat: %s", verb, chat_id)
self._chat_info_cache.pop(chat_id, None)
def _on_bot_added_to_chat(self, data: Any) -> None:
self._on_bot_membership_change(data, "added to")
def _on_bot_removed_from_chat(self, data: Any) -> None:
self._on_bot_membership_change(data, "removed from")
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 _submit_if_ready(self, label: str, make_coro: Any) -> None:
"""Schedule ``make_coro()`` on the adapter loop, or log-and-drop when the loop isn't ready."""
loop = self._loop
if not self._loop_accepts_callbacks(loop):
logger.warning("[Feishu] Dropping %s before adapter loop is ready", label)
return
self._submit_on_loop(loop, make_coro())
def _on_drive_comment_event(self, data: Any) -> None:
"""drive.notice.comment_add_v1 → feishu_comment.handle_drive_comment_event on the adapter loop."""
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
self._submit_if_ready(
"drive comment event",
lambda: handle_drive_comment_event(self._client, data, self_open_id=self._bot_open_id),
)
def _on_meeting_invited_event(self, data: Any) -> None:
"""vc.bot.meeting_invited_v1 → feishu_meeting_invite.handle_meeting_invited_event."""
from plugins.platforms.feishu.feishu_meeting_invite import handle_meeting_invited_event
self._submit_if_ready("meeting invite event", lambda: 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 clicking the same emoji is still routed through.
loop = self._loop
if operator_type in {"bot", "app"} or not message_id or not self._loop_accepts_callbacks(loop):
return
self._submit_on_loop(loop, self._handle_reaction_event(event_type, data))
def _on_card_action_trigger(self, data: Any) -> Any:
"""Synchronous SDK card-action callback.
Approval/update-prompt buttons return the resolved card inline (the only reliable way
to sync all clients) and schedule the async resolution; other clicks are routed as
synthetic commands via ``_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 self._card_response()
event = getattr(data, "event", None)
action = getattr(event, "action", None)
action_value = getattr(action, "value", {}) or {}
if isinstance(action_value, dict):
if action_value.get("hermes_action"):
return self._handle_approval_card_action(event=event, action_value=action_value, loop=loop)
if action_value.get("hermes_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))
return self._card_response()
@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
@staticmethod
def _card_response(card_data: Optional[Dict[str, Any]] = None) -> Any:
"""Synchronous card-callback response; ``card_data`` updates the card inline."""
if P2CardActionTriggerResponse is None:
return None
response = P2CardActionTriggerResponse()
if card_data is not None and CallBackCard is not None:
card = CallBackCard()
card.type = "raw"
card.data = card_data
response.card = card
return response
def _validate_card_action(
self, *, event: Any, state: Dict[str, str], label: str, ident: Any,
) -> Optional[tuple[str, str, str]]:
"""Shared operator/chat checks for approval + update-prompt clicks.
Returns ``(open_id, callback_chat_id, user_name)`` or None (already logged).
"""
operator = getattr(event, "operator", None)
open_id = str(getattr(operator, "open_id", "") or "")
if not self._is_interactive_operator_authorized(open_id):
logger.warning("[Feishu] Unauthorized %s click by %s", label, open_id or "<unknown>")
return 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] %s callback chat mismatch for %s (expected=%s, got=%s)",
label.capitalize(), ident, expected_chat_id, callback_chat_id,
)
return None
return open_id, callback_chat_id, self._get_cached_sender_name(open_id) or open_id
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 self._card_response()
state = self._approval_state.get(approval_id)
if not state:
logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id)
return self._card_response()
choice = _APPROVAL_CHOICE_MAP.get(action_value.get("hermes_action"), "deny")
checked = self._validate_card_action(event=event, state=state, label="approval", ident=approval_id)
if checked is None:
return self._card_response()
open_id, chat_id, user_name = checked
coro = self._resolve_approval(
approval_id=approval_id, choice=choice, user_name=user_name, open_id=open_id, chat_id=chat_id,
)
if not self._submit_on_loop(loop, coro):
return self._card_response()
return self._card_response(self._build_resolved_approval_card(choice=choice, user_name=user_name))
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 self._card_response()
state = self._update_prompt_state.get(prompt_id)
if not state:
logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id)
return self._card_response()
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 self._card_response()
checked = self._validate_card_action(event=event, state=state, label="update prompt", ident=prompt_id)
if checked is None:
return self._card_response()
open_id, chat_id, user_name = checked
coro = self._resolve_update_prompt(prompt_id, answer, user_name, open_id=open_id, chat_id=chat_id)
if not self._submit_on_loop(loop, coro):
return self._card_response()
return self._card_response(self._build_resolved_update_prompt_card(answer=answer, user_name=user_name))
def _pop_validated_prompt_state(
self, *, states: Dict[int, Dict[str, str]], ident: Any, label: str, open_id: str, chat_id: str,
unauthorized_fmt: str, operator_repr: str,
) -> Optional[Dict[str, str]]:
"""Re-validate on the loop thread (state may have changed since the callback) and pop."""
state = states.get(ident)
if not state:
logger.debug("[Feishu] %s %s already resolved or unknown", label, ident)
return None
if not self._is_interactive_operator_authorized(open_id):
logger.warning(unauthorized_fmt, operator_repr, ident)
return None
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] %s %s chat mismatch (expected=%s, got=%s)", label, ident, expected_chat_id, chat_id)
return None
state = states.pop(ident, None)
if not state:
logger.debug("[Feishu] %s %s already resolved while validating callback", label, ident)
return state
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._pop_validated_prompt_state(
states=self._approval_state, ident=approval_id, label="Approval", open_id=open_id, chat_id=chat_id,
unauthorized_fmt="[Feishu] Unauthorized approval click by %s for approval %s",
operator_repr=open_id or "<unknown>",
)
if not state:
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,
)
if not count and choice != "deny":
# The card already reads "Approved" (synchronous callback), but nothing was
# waiting — the wait timed out (fail-closed deny) or was resolved via /approve.
# Correct the record so the user doesn't believe the command ran.
_chat = str(state.get("chat_id", "") or chat_id or "")
if _chat:
try:
await self.send(
_chat,
"⌛ That approval had already expired — the command "
"was not run (it timed out or was resolved elsewhere).",
)
except Exception:
logger.debug("[Feishu] expired-approval notice failed", exc_info=True)
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._pop_validated_prompt_state(
states=self._update_prompt_state, ident=prompt_id, label="Update prompt", open_id=open_id,
chat_id=chat_id, unauthorized_fmt="[Feishu] Unauthorized update prompt click by %s for prompt %s",
operator_repr=open_id,
)
if not state:
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 self._response_succeeded(response):
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 reports sender.id=app_id for bot messages — peer bots share
# sender_type="app" with us but differ on app_id. Only route our own messages.
sender = getattr(msg, "sender", None)
if str(getattr(sender, "id", "") or "") != self._app_id:
return
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}"
logger.info("[Feishu] Routing reaction %s:%s on bot message %s as synthetic event", action, emoji_type, message_id)
await self._dispatch_synthetic_event(
text=synthetic_text, message_type=MessageType.TEXT, chat_id=chat_id, sender_id=user_id_obj,
event_chat_type=chat_type_raw, raw_message=data, message_id=message_id,
)
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
logger.info("[Feishu] Routing card action %r from %s in %s as synthetic command", action_tag, open_id, chat_id)
await self._dispatch_synthetic_event(
text=synthetic_text, message_type=MessageType.COMMAND, chat_id=chat_id,
sender_id=SimpleNamespace(open_id=open_id, user_id=None, union_id=None), event_chat_type="group",
raw_message=data, message_id=token or str(uuid.uuid4()),
)
async def _dispatch_synthetic_event(
self, *, text: str, message_type: MessageType, chat_id: str, sender_id: Any, event_chat_type: str,
raw_message: Any, message_id: str,
) -> None:
"""Wrap a reaction/card click as a MessageEvent and run it through the guarded pipeline."""
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=event_chat_type),
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=text, message_type=message_type, source=source, raw_message=raw_message,
message_id=message_id, channel_prompt=self._resolve_channel_prompt(chat_id),
timestamp=datetime.now(),
)
await self._handle_message_with_guards(synthetic_event)
# --- Per-chat serialization and typing indicator ---
def _get_chat_lock(self, chat_id: str) -> asyncio.Lock:
"""Per-chat asyncio.Lock for serial processing; LRU-bounded, never evicts a held lock if any is free."""
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:
victim = next((k for k, lk in self._chat_locks.items() if not lk.locked()), next(iter(self._chat_locks)))
self._chat_locks.pop(victim)
lock = asyncio.Lock()
self._chat_locks[chat_id] = lock
return lock
async def _handle_message_with_guards(self, event: MessageEvent) -> None:
"""Run one event through the agent pipeline under the per-chat lock (openclaw createChatQueue)."""
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 str(_get_scoped_secret("FEISHU_REACTIONS", "true")).strip().lower() not in {"false", "0", "no"}
async def _reaction_call(self, verb: str, message_id: str, ident: str, build_request: Any, api: Any) -> Any:
"""Shared add/remove reaction wrapper: returns the response data on success, else None (logged)."""
try:
response = await self._run_blocking(api, build_request())
if self._response_succeeded(response):
return getattr(response, "data", None) or True
logger.debug(
"[Feishu] %s reaction %s on %s rejected: code=%s msg=%s",
verb, ident, message_id, getattr(response, "code", None), getattr(response, "msg", None),
)
except Exception:
logger.warning("[Feishu] %s reaction %s on %s raised", verb, ident, message_id, exc_info=True)
return None
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
def _build() -> Any: # lazy SDK import stays inside the guarded call
from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody
body = CreateMessageReactionRequestBody.builder().reaction_type({"emoji_type": emoji_type}).build()
return CreateMessageReactionRequest.builder().message_id(message_id).request_body(body).build()
data = await self._reaction_call("Add", message_id, emoji_type, _build, self._client.im.v1.message_reaction.create)
return getattr(data, "reaction_id", None) if data is not None else 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
def _build() -> Any:
from lark_oapi.api.im.v1 import DeleteMessageReactionRequest
return DeleteMessageReactionRequest.builder().message_id(message_id).reaction_id(reaction_id).build()
data = await self._reaction_call("Remove", message_id, reaction_id, _build, self._client.im.v1.message_reaction.delete)
return data is not None
async def on_processing_start(self, event: MessageEvent) -> None:
message_id = event.message_id
if not self._reactions_enabled() or 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:
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)
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
message_id = event.message_id
if not self._reactions_enabled() or 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 a Typing we couldn't remove (UI would read as both
# "working" and "done/failed"); keep the handle so LRU eventually evicts it.
return
self._pending_processing_reactions.pop(message_id, None)
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:
"""Count consecutive error responses per IP (openclaw createWebhookAnomalyTracker); WARN every threshold."""
now = time.time()
count, _last_status, first_seen = self._webhook_anomaly_counts.get(remote_ip) or (0, "", now)
if count and now - first_seen >= _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS:
count, first_seen = 0, now # TTL expired — start fresh
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)
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:
"""Honour ``channel_prompts: {<chat_id>: "<prompt>"}`` in PlatformConfig.extra (as Discord/Slack do)."""
from gateway.platforms.base import resolve_channel_prompt
extra = getattr(getattr(self, "config", None), "extra", None) or {} # tests build bare adapters
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, media_text_inlined, 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
# Post-strip guard 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
# Only a native ``thread_id`` marks a topic. ``root_id`` is present on every quoted reply
# too, so using it as a fallback (#19711) turned ordinary quote replies into topic
# sessions and pushed the bot's answer into a fresh thread (#20548).
thread_id = getattr(message, "thread_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 "<unknown>"
)
chat_id = getattr(message, "chat_id", "") 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, chat_id,
"bot" if is_bot else "user", sender_primary, text[:120], len(media_urls),
)
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,
message_id=message_id,
)
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,
media_text_inlined=media_text_inlined,
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:
batchable = {MessageType.PHOTO, MessageType.VIDEO, MessageType.DOCUMENT, MessageType.AUDIO}
return bool(event.media_urls and event.message_type in batchable)
def _media_batch_key(self, event: MessageEvent) -> str:
return f"{self._text_batch_key(event)}:media:{event.message_type.value}"
@staticmethod
def _media_batch_is_compatible(existing: MessageEvent, incoming: MessageEvent) -> bool:
return existing.message_type == incoming.message_type and FeishuAdapter._text_batch_is_compatible(existing, incoming)
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)
existing.media_text_inlined.extend(event.media_text_inlined)
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
existing.source.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:
await self._delayed_flush(
self._pending_media_batch_tasks, key, self._media_batch_delay_seconds, self._flush_media_batch_now,
)
@staticmethod
async def _delayed_flush(task_map: Dict[str, asyncio.Task], key: str, delay: float, flush_now: Any) -> None:
"""Sleep ``delay`` then flush; drop our own task handle from ``task_map`` (not a successor's)."""
current_task = asyncio.current_task()
try:
await asyncio.sleep(delay)
await flush_now(key)
finally:
if task_map.get(key) is current_task:
task_map.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 gateway.platforms.base import _ssrf_redirect_guard
from tools.url_safety import create_ssrf_safe_async_client, is_safe_url
if not is_safe_url(file_url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {file_url[:80]}")
async with create_ssrf_safe_async_client(
timeout=30.0, follow_redirects=True, event_hooks={"response": [_ssrf_redirect_guard]},
) as client:
response = await client.get(
file_url, headers={"User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)", "Accept": "*/*"},
)
response.raise_for_status()
# Snapshot headers + body inside the context so pooled connections fully release.
# 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,
)
return await cache_document_from_bytes_async(body, filename), 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
def _webhook_reject(self, remote_ip: str, anomaly: str, status: int, text: Optional[str] = None,
json_msg: Optional[str] = None) -> Any:
"""Record an anomaly for ``remote_ip`` and build the matching aiohttp error response."""
self._record_webhook_anomaly(remote_ip, anomaly)
if json_msg is not None:
return web.json_response({"code": status, "msg": json_msg}, status=status)
return web.Response(status=status, text=text)
async def _handle_webhook_request(self, request: Any) -> Any:
remote_ip = (getattr(request, "remote", None) or "unknown")
# Rate-limit key is app_id:path:remote_ip (matches openclaw key structure).
if not self._check_webhook_rate_limit(f"{self._app_id}:{self._webhook_path}:{remote_ip}"):
logger.warning("[Feishu] Webhook rate limit exceeded for %s", remote_ip)
return self._webhook_reject(remote_ip, "429", 429, "Too Many Requests")
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": # Feishu always sends JSON
logger.warning("[Feishu] Webhook rejected: unexpected Content-Type %r from %s", content_type, remote_ip)
return self._webhook_reject(remote_ip, "415", 415, "Unsupported Media Type")
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)
return self._webhook_reject(remote_ip, "413", 413, "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)
return self._webhook_reject(remote_ip, "413", 413, "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)
return self._webhook_reject(remote_ip, "408", 408, "Request Timeout")
except Exception:
return self._webhook_reject(remote_ip, "400", 400, json_msg="failed to read body")
try:
payload = json.loads(body_bytes.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return self._webhook_reject(remote_ip, "400", 400, json_msg="invalid json")
# Verification token: second defence layer beyond the signature (matches openclaw).
if self._verification_token:
header = payload.get("header") or {}
incoming_token = str(header.get("token") or payload.get("token") or "")
# compare_digest as bytes — it raises TypeError on non-ASCII str, and the token is remote input.
if not incoming_token or not hmac.compare_digest(
incoming_token.encode(), self._verification_token.encode()
):
logger.warning("[Feishu] Webhook rejected: invalid verification token from %s", remote_ip)
return self._webhook_reject(remote_ip, "401-token", 401, "Invalid verification token")
# Token is validated above BEFORE reflecting the challenge, so an unauthenticated
# remote can't prove endpoint control by getting its own challenge echoed back.
if payload.get("type") == "url_verification":
return web.json_response({"challenge": payload.get("challenge", "")})
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)
return self._webhook_reject(remote_ip, "401-sig", 401, "Invalid signature")
if payload.get("encrypt"):
logger.error("[Feishu] Encrypted webhook payloads are not supported by Hermes webhook mode")
return self._webhook_reject(
remote_ip, "400-encrypted", 400, json_msg="encrypted webhook payloads are not supported",
)
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 in {"im.message.reaction.created_v1", "im.message.reaction.deleted_v1"}:
self._on_reaction_event(event_type, data)
else:
handler = self._WEBHOOK_EVENT_HANDLERS.get(event_type)
if handler is None:
logger.debug("[Feishu] Ignoring webhook event type: %s", event_type or "unknown")
else:
getattr(self, handler)(data)
return web.json_response({"code": 0, "msg": "ok"})
# Webhook event_type -> handler method name (reaction events are routed separately).
_WEBHOOK_EVENT_HANDLERS = {
"im.message.receive_v1": "_on_message_event",
"im.message.message_read_v1": "_on_message_read_event",
"im.chat.member.bot.added_v1": "_on_bot_added_to_chat",
"im.chat.member.bot.deleted_v1": "_on_bot_removed_from_chat",
"card.action.trigger": "_on_card_action_trigger",
"drive.notice.comment_add_v1": "_on_drive_comment_event",
"vc.bot.meeting_invited_v1": "_on_meeting_invited_event",
}
def _is_webhook_signature_valid(self, headers: Any, body_bytes: bytes) -> bool:
"""Timing-safe check of x-lark-signature == SHA256(timestamp + nonce + encrypt_key + body)."""
timestamp, nonce, signature = (
str(headers.get(name, "") or "")
for name in ("x-lark-request-timestamp", "x-lark-request-nonce", "x-lark-signature")
)
if not timestamp or not nonce or not signature:
return False
try:
body_str = body_bytes.decode("utf-8", errors="replace")
computed = hashlib.sha256(f"{timestamp}{nonce}{self._encrypt_key}{body_str}".encode("utf-8")).hexdigest()
# Compare as bytes: compare_digest raises TypeError on non-ASCII str, and the header is remote input.
return hmac.compare_digest(computed.encode(), signature.encode())
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:
"""Sliding-window limiter keyed by "{app_id}:{path}:{remote_ip}" (openclaw); table capped, fail-closed."""
now = time.time()
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 or new key — prune stale entries when at capacity.
if len(self._webhook_rate_counts) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:
for k in [k for k, (_, ws) in self._webhook_rate_counts.items() if now - ws >= _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS]:
del self._webhook_rate_counts[k]
# Still full → deny untracked keys (fail closed): the table only fills this far under
# abuse, and letting untracked requests through would 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 ---
@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 "")
def _start_batch() -> None:
self._pending_text_batches[key] = event
self._pending_text_batch_counts[key] = 1
self._schedule_text_batch_flush(key)
existing = self._pending_text_batches.get(key)
if existing is None:
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
_start_batch()
return
if not self._text_batch_is_compatible(existing, event):
await self._flush_text_batch_now(key)
_start_batch()
return
next_count = self._pending_text_batch_counts.get(key, 1) + 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)
_start_batch()
return
existing.text = next_text
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
existing.media_text_inlined.extend(event.media_text_inlined)
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
existing.timestamp = event.timestamp
if event.message_id:
existing.message_id = event.message_id
existing.source.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))
def _pop_text_batch(self, key: str) -> Optional[MessageEvent]:
self._pending_text_batch_counts.pop(key, None)
return self._pending_text_batches.pop(key, None)
async def _dispatch_text_batch(self, event: MessageEvent) -> None:
await self._handle_message_with_guards(event)
# --- Message content extraction and resource download ---
def _normalize(self, message_type: Any, raw_content: Any, mentions: Any) -> FeishuNormalizedMessage:
return normalize_feishu_message(
message_type=message_type, raw_content=raw_content, mentions=mentions, bot=self._bot_identity(),
)
async def _extract_message_content(
self, message: Any
) -> tuple[str, MessageType, List[str], List[str], List[bool], 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 = self._normalize(raw_type, raw_content, getattr(message, "mentions", None))
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
media_text_inlined: List[bool] = []
inlined_parts: List[str] = []
for media_url, media_type in zip(media_urls, media_types):
extracted = await self._maybe_extract_text_document(media_url, media_type)
media_text_inlined.append(bool(extracted))
if extracted:
inlined_parts.append(extracted)
if inlined_parts:
extracted_text = "\n\n".join(inlined_parts)
text = f"{text}\n\n{extracted_text}" if text else extracted_text
return text, inbound_type, media_urls, media_types, media_text_inlined, 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] = []
def _collect(cached_path: str, media_type: str) -> None:
if cached_path:
media_urls.append(cached_path)
media_types.append(media_type)
for image_key in normalized.image_keys:
_collect(*await self._download_feishu_image(message_id=message_id, image_key=image_key))
for ref in normalized.media_refs:
_collect(*await self._download_feishu_message_resource(
message_id=message_id, file_key=ref.file_key, resource_type=ref.resource_type,
fallback_filename=ref.file_name,
))
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 == "audio":
# Lark's native "audio" is an in-app voice recording (uploaded audio arrives as
# file/media → "document"). VOICE makes the gateway auto-transcribe it like
# Discord/DingTalk/Telegram; as AUDIO it would be silently ignored.
# Classify it as VOICE so the gateway auto-transcribes it (Opus → STT) the same way
# Discord/DingTalk/Telegram/etc. do — otherwise a Feishu voice note reaches the agent as an
# untranscribable AUDIO attachment and is silently ignored. Follow-up to #28993, which added
# native voice-note transcription for Discord + DingTalk.
return MessageType.VOICE
if preferred in ("photo", "document"):
default = MessageType.PHOTO if preferred == "photo" else MessageType.DOCUMENT
return self._resolve_media_message_type(media_types[0] if media_types else "", default=default)
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 _fetch_message_resource(self, *, message_id: str, file_key: str, resource_type: str) -> Any:
"""GET im/v1/messages/{id}/resources/{key}; returns the SDK response (caller checks success)."""
request = self._build_message_resource_request(
message_id=message_id, file_key=file_key, resource_type=resource_type,
)
return await self._run_blocking(self._client.im.v1.message_resource.get, request)
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:
response = await self._fetch_message_resource(message_id=message_id, file_key=image_key, resource_type="image")
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 = await cache_image_from_bytes_async(raw_bytes, ext=ext)
return cached_path, self._normalize_media_type(content_type, default=self._default_image_media_type(ext))
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 "", ""
# audio/media uploads are sometimes only retrievable as type=file.
request_types = [resource_type] + (["file"] if resource_type in {"audio", "media"} else [])
for request_type in request_types:
try:
response = await self._fetch_message_resource(
message_id=message_id, file_key=file_key, resource_type=request_type,
)
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")
filename = (getattr(response, "file_name", None) or "") 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)
kind, cached_path = "image", await cache_image_from_bytes_async(raw_bytes, ext=ext)
media_type = media_type or self._default_image_media_type(ext)
elif request_type == "audio" or media_type.startswith("audio/"):
ext = self._guess_extension(filename, content_type, ".ogg", allowed=_AUDIO_EXTENSIONS)
kind, cached_path = "audio", await cache_audio_from_bytes_async(raw_bytes, ext=ext)
media_type = media_type or f"audio/{ext.lstrip('.') or 'ogg'}"
elif media_type.startswith("video/"):
if not Path(filename).suffix:
filename = f"{filename}.mp4"
kind, cached_path = "video", await cache_document_from_bytes_async(raw_bytes, filename)
else:
if not Path(filename).suffix and media_type in _DOCUMENT_MIME_TO_EXT:
filename = f"{filename}{_DOCUMENT_MIME_TO_EXT[media_type]}"
kind, cached_path = "document", await cache_document_from_bytes_async(raw_bytes, filename)
media_type = media_type or self._guess_document_media_type(filename)
logger.info("[Feishu] Cached message %s resource at %s", kind, cached_path)
return cached_path, media_type
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 "")
return guessed if guessed in allowed else 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 any(marker in normalized for marker in ("topic", "thread", "forum")):
return "forum"
return "group" if normalized == "group" else "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
return "dm" if event_chat_type == "p2p" else "group"
async def _resolve_sender_profile(self, sender_id: Any, *, is_bot: bool = False) -> Dict[str, Optional[str]]:
"""Map Feishu's ID tiers onto SessionSource: user_id (tenant) > open_id (app) as primary,
union_id (developer-scoped, cross-app stable) as user_id_alt — session keys prefer the alt."""
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
primary_id = user_id or open_id
name_lookup_id = open_id if is_bot else (primary_id or union_id) # bots/basic_batch only takes open_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."""
cached = self._sender_name_cache.get(sender_id) if sender_id else None
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 go via bot/basic_batch (contact API has no bot names). Failures are silent — never block the pipeline."""
trimmed = sender_id.strip() if sender_id and self._client else ""
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
for oid, name in names.items():
self._sender_name_cache[oid] = (name, now + _FEISHU_SENDER_NAME_TTL_SECONDS)
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
id_type = "open_id" if trimmed.startswith("ou_") else "union_id" if trimmed.startswith("on_") else "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)
candidates = (getattr(user, k, None) for k in ("name", "display_name", "nickname", "en_name"))
name = next((value for value in candidates if value), None)
if name and isinstance(name, str) and name.strip():
name = name.strip()
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 _tenant_get_raw(self, uri: str, *, queries: Optional[List[tuple[str, str]]] = None) -> Any:
"""GET ``uri`` with the tenant token via the raw client; returns the raw response content."""
resp = await self._run_blocking(self._client.request, _tenant_get_request(uri, queries=queries))
return getattr(getattr(resp, "raw", None), "content", 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:
content = await self._tenant_get_raw(
"/open-apis/bot/v3/bots/basic_batch", queries=[("bot_ids", oid) for oid in bot_ids],
)
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 = self._normalize(msg_type, raw_content, mentions)
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 not in ("mentions", "all"):
return "bots_disabled"
if not self_ids or not sender_ids: # pre-hydration or malformed payloads
return "self_ids_unknown"
# The group step below enforces mentions when require_mention is on; cover the rest here.
if mode == "mentions" and not require_mention and not self._mentions_self(message):
return "bot_not_mentioned"
if not is_group:
# _allow_all_dm is snapshotted per-profile in _load_settings: _admit runs on the
# lark_oapi WS thread with no secret scope, so a bare os.getenv would read the
# default profile's value.
# Empty FEISHU_ALLOWED_USERS is setup's pairing-mode default: forward DMs so the
# pairing handshake can run (gateway auth fail-closes until approval).
if self._allow_all_dm or not self._allowed_group_users:
return None
return None if sender_ids & self._allowed_group_users else "dm_policy_rejected"
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
# Class default so the first drop after construction is the one that warns.
_warned_empty_allowlist_deny = False
def _warn_once_empty_allowlist_deny(self, chat_id: str) -> None:
"""One WARNING when group traffic dies on the untouched allowlist default (#111420).
The policy read is scoped per profile on purpose — never inherit another profile's
FEISHU_GROUP_POLICY here; only make the resulting deny visible above DEBUG.
"""
if self._warned_empty_allowlist_deny:
return
from plugins.platforms.feishu.feishu_admission_diagnostics import empty_allowlist_drop_warning
text = empty_allowlist_drop_warning(
chat_id=chat_id, group_rules=self._group_rules,
default_group_policy=self._default_group_policy, allowed_group_users=self._allowed_group_users,
)
if text:
self._warned_empty_allowlist_deny = True
logger.warning(text)
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
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_ids = {getattr(sender_id, "open_id", None), getattr(sender_id, "user_id", None)} - {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, allowlist, blacklist = rule.policy, rule.allowlist, rule.blacklist
else:
policy, allowlist, blacklist = self._default_group_policy or self._group_policy, self._allowed_group_users, set()
# Channel locks apply to everyone; allowlist/blacklist only gate humans (bots were
# already cleared upstream by FEISHU_ALLOW_BOTS).
if policy in ("disabled", "admin_only"):
return False
if policy == "open" or 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))
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 = self._normalize(getattr(message, "message_type", "") or "", raw_content, getattr(message, "mentions", None))
return self._post_mentions_bot(normalized.mentions)
def _message_mentions_bot(self, mentions: List[Any]) -> bool:
# Same precedence as _FeishuBotIdentity.matches (open_id > user_id > name); a non-empty
# bot_name here only matches an exact stripped name, while an empty bot_name never matches.
bot = self._bot_identity()
for mention in mentions:
mention_id = getattr(mention, "id", None)
if bot.matches(
open_id=(getattr(mention_id, "open_id", None) or "").strip(),
user_id=(getattr(mention_id, "user_id", None) or "").strip(),
name=(getattr(mention, "name", None) or "").strip(),
):
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 bot identity discovery for mention gating and self-event filtering.
/bot/v3/info (tenant token, no extra scopes) always wins over env values so stale
FEISHU_BOT_* from app migrations can't break gating; the application-info endpoint is
a name-only fallback. On failure env-provided values are kept.
"""
if not self._client:
return
try:
content = await self._tenant_get_raw("/open-apis/bot/v3/info")
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)
if self._bot_name:
return
# Name-only fallback; needs admin:app.info:readonly or application:application:self_manage.
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
if isinstance(seen_data, list): # legacy format: plain list of IDs, no timestamps
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 isinstance(key, str) and key.strip() and isinstance(value, (int, float, str)):
try:
entries[key] = float(value)
except ValueError:
pass
else:
return
# Drop TTL-expired entries; ts=0.0 (legacy) is immortal for one migration cycle.
valid: Dict[str, float] = {m: ts for m, ts in entries.items() if ts == 0.0 or ttl <= 0 or now - ts < ttl}
# Size cap keeps 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:
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(self._dedup_state_path.parent)
with self._dedup_lock:
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)
async def _is_duplicate(self, message_id: str) -> bool:
now, ttl = time.time(), _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
self._seen_message_ids[message_id] = now # wall-clock so TTL survives restarts
self._seen_message_order.append(message_id)
while len(self._seen_message_order) > self._dedup_cache_size:
self._seen_message_ids.pop(self._seen_message_order.pop(0), None)
# atomic_json_write() fsyncs; this runs on the event loop for every inbound message, so
# offload the flush onto the adapter-owned pool: the loop's default executor may already
# be torn down by a dead background loop, which used to wedge every inbound message in
# the dedup gate (#111020). The lock keeps flushes in mutation order (the snapshot
# inside the worker is taken under _dedup_lock, but the write itself is not).
async with self._dedup_persist_lock_or_create():
await self._run_blocking(self._persist_seen_message_ids)
return False
def _dedup_persist_lock_or_create(self) -> asyncio.Lock:
# Tests build bare adapters via object.__new__ and install dedup state
# by hand; create the lock lazily so those fixtures keep working.
lock = getattr(self, "_dedup_persist_lock", None)
if lock is None:
lock = self._dedup_persist_lock = asyncio.Lock()
return lock
# --- Outbound payload construction and send pipeline ---
def _build_outbound_payload(self, content: str, *, prefer_post: bool = False) -> tuple[str, str]:
# Feishu clients render markdown tables inside ``post`` ``md`` elements natively, so tables
# take the common markdown path (no text downgrade). ``prefer_post`` lets ``send`` keep every
# chunk of a split markdown reply as ``post`` even when a chunk alone looks like prose.
# The previous table-downgrade branch forced any table-containing message to ``text``, which left
# Feishu readers seeing the raw pipe-and-dash source instead of a rendered table. ``prefer_post``
# lets ``send`` treat the chunk as part of a larger markdown document: when a long markdown reply is
# split at MAX_MESSAGE_LENGTH, the per-chunk regex would otherwise mis-classify a plain-prose chunk
# as ``text``. See #26841.
if prefer_post or _MARKDOWN_HINT_RE.search(content):
return "post", _build_markdown_post_payload(content)
return "text", json.dumps({"text": content}, ensure_ascii=False)
@staticmethod
def _get_audio_duration_ms(file_path: str) -> int:
"""OGG/Opus duration in ms (pure Python): last granule position / 48000 Hz; 0 on non-OGG or error."""
import struct
try:
with open(file_path, "rb") as f:
data = f.read()
pos = last_granule = 0
while pos < len(data) - 27:
pos = data.find(b"OggS", pos)
if pos == -1 or pos + 27 > len(data):
break
granule = struct.unpack_from("<q", data, pos + 6)[0]
num_segments = data[pos + 26]
last_granule = granule if granule > 0 else last_granule
if pos + 27 + num_segments > len(data):
break
pos += num_segments + sum(data[pos + 27 : pos + 27 + num_segments])
return int(last_granule / 48000 * 1000) if last_granule > 0 else 0
except Exception:
return 0
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:
duration_ms = self._get_audio_duration_ms(file_path) if upload_file_type == "opus" else 0
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, duration=duration_ms,
)
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",
)
key_payload = {"file_key": file_key}
message_response = await self._send_uploaded_key(
chat_id=chat_id, reply_to=reply_to, metadata=metadata, caption=caption,
key_msg_type=resolved_message_type, key_payload=key_payload,
media_tag={"tag": "media", "file_key": file_key, "file_name": display_name},
)
# Audio may fail with 99992402 under thread_id routing: retry as a reply to the
# thread's last message, then fall back to a plain chat_id send.
if (not caption
and not self._response_succeeded(message_response)
and getattr(message_response, "code", None) == 99992402
and resolved_message_type == "audio"
and (metadata or {}).get("thread_id")):
payload = json.dumps(key_payload, ensure_ascii=False)
thread_msg_id = (metadata or {}).get("reply_to_message_id")
if not thread_msg_id:
thread_msg_id = await self._fetch_last_message_in_thread((metadata or {}).get("thread_id"))
if thread_msg_id:
logger.info("[Feishu] Audio: retrying via reply API in thread")
message_response = await self._feishu_send_with_retry(
chat_id=chat_id, msg_type="audio", payload=payload, reply_to=thread_msg_id, metadata=metadata,
)
if not self._response_succeeded(message_response):
logger.warning("[Feishu] Audio send failed in thread, retrying with chat_id")
message_response = await self._feishu_send_with_retry(
chat_id=chat_id, msg_type="audio", payload=payload, reply_to=None, metadata=None,
)
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_uploaded_key(
self, *, chat_id: str, reply_to: Optional[str], metadata: Optional[Dict[str, Any]], caption: Optional[str],
key_msg_type: str, key_payload: Dict[str, str], media_tag: Dict[str, str],
) -> Any:
"""Send an uploaded image/file key: as a captioned ``post`` or as a bare key message."""
if caption:
msg_type = "post"
payload = self._build_media_post_payload(caption=caption, media_tag=media_tag)
else:
msg_type = key_msg_type
payload = json.dumps(key_payload, ensure_ascii=False)
return await self._feishu_send_with_retry(
chat_id=chat_id, msg_type=msg_type, payload=payload, reply_to=reply_to, metadata=metadata,
)
async def _fetch_last_message_in_thread(self, thread_id: str) -> Optional[str]:
"""Fetch the last message_id in a thread for reply-based routing."""
if not self._client or not thread_id:
return None
try:
from lark_oapi.api.im.v1 import ListMessageRequest
request = ListMessageRequest.builder().container_id_type("thread").container_id(thread_id).page_size(1).build()
response = await self._run_blocking(self._client.im.v1.message.list, request)
if self._response_succeeded(response):
items = getattr(getattr(response, "data", None), "items", None)
if items and len(items) > 0:
return getattr(items[0], "message_id", None)
except Exception as exc:
logger.debug("[Feishu] Failed to fetch last message in thread %s: %s", thread_id, exc)
return None
async def _send_raw_message(
self, *, chat_id: str, msg_type: str, payload: str, reply_to: Optional[str], metadata: Optional[Dict[str, Any]],
) -> Any:
thread_id = (metadata or {}).get("thread_id")
effective_reply_to = reply_to or ((metadata or {}).get("reply_to_message_id") if thread_id else None)
if effective_reply_to:
body = self._build_reply_message_body(
content=payload, msg_type=msg_type, reply_in_thread=bool(thread_id), 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)
if thread_id:
# reply→create fallback inside a topic: thread_id as receive_id keeps it in the topic.
receive_id, receive_id_type = thread_id, "thread_id"
elif chat_id.startswith("feishu_user_id:"):
receive_id, receive_id_type = chat_id.split(":", 1)[1], "user_id"
else:
receive_id, receive_id_type = chat_id, "open_id" if chat_id.startswith("ou_") else "chat_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:
data = getattr(response, "data", None) if FeishuAdapter._response_succeeded(response) else 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 _supervise_websocket_thread(self) -> None:
"""Restart the WS client thread if it dies while the adapter is up.
``lark_oapi.start()`` only returns on fatal errors; without this watcher a dead thread
left the profile silently deaf until a gateway restart. Rebuild with capped backoff.
See #73779.
"""
backoff = initial_backoff = float(self._ws_restart_backoff)
last_dead: Optional[asyncio.Future] = None
while self._running:
ws_future = self._ws_future
if ws_future is None:
return
try:
await asyncio.shield(ws_future)
except asyncio.CancelledError:
raise
except Exception:
pass
# Deliberate disconnects nil ``_ws_client``/``_running`` first; only restart a live link.
if not self._running or self._ws_client is None:
return
if ws_future is not last_dead:
logger.error("[Feishu] WebSocket client thread exited unexpectedly; restarting in %.0fs", backoff)
last_dead = ws_future
# Still running, link unproven: ``connected`` stays wrong until ``_ws_link_up`` re-stamps it.
self._write_runtime_status_safe(
"ws_link_lost", platform_state="retrying", error_code=None,
error_message="Feishu websocket link lost; rebuilding",
)
await asyncio.sleep(backoff)
if not self._running:
return
try:
await self._connect_websocket()
backoff = initial_backoff
except Exception as exc:
logger.warning("[Feishu] WebSocket restart failed (retrying): %s", exc)
backoff = min(backoff * 2, 60.0)
def _ws_link_up(self, ws_client: Any) -> None:
"""WS thread reports its link is up (SDK receive loop scheduled); re-stamp ``connected`` after a rebuild."""
if self._running and self._ws_client is ws_client:
self._mark_connected()
def _ws_link_retrying(self, ws_client: Any) -> None:
"""WS thread reports the SDK's own reconnect ladder started; ``_ws_link_up`` re-stamps ``connected``."""
if self._running and self._ws_client is ws_client:
self._write_runtime_status_safe(
"ws_link_lost", platform_state="retrying", error_code=None,
error_message="Feishu websocket link lost; reconnecting",
)
async def _connect_websocket(self) -> None:
if not FEISHU_WEBSOCKET_AVAILABLE:
raise RuntimeError("websockets not installed; websocket mode unavailable")
domain = self._prepare_client()
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,
# Without the "channel" UA tag Feishu won't push group @mention events over WS.
extra_ua_tags=["channel"],
)
# The lark SDK owns this thread and fires every event/card callback on it; those hop back
# to the adapter loop via run_coroutine_threadsafe, which copies the CALLER's context — so
# whatever scope the WS thread carries is what pre-handler work (inbound media caching,
# .update_response marker, reactions env, drive comments) runs under. A bare executor
# thread has an empty context = launch profile. connect() runs inside the profile scope
# under multiplex (and the supervisor task inherits it), so snapshot it here.
self._ws_future = loop.run_in_executor(
self._get_sdk_executor(), contextvars.copy_context().run, _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")
self._prepare_client()
await self._hydrate_bot_identity()
# client_max_size backstops the bounded reader in _handle_webhook_request on every read path.
# See #58536, #58902, #59180.
app = web.Application(client_max_size=_FEISHU_WEBHOOK_MAX_BODY_BYTES)
app.router.add_post(self._webhook_path, self._handle_webhook_request)
# Shared-listener mode (multiplex secondary): no bind; served at /p/<profile>/<webhook_path>.
from gateway.platforms.shared_ingress import bind_listener
self._webhook_runner = await bind_listener(self, app, self._webhook_host, self._webhook_port, self._webhook_path)
def _prepare_client(self) -> Any:
"""Build the lark client + event dispatcher for this adapter's domain; returns the SDK domain."""
domain = _sdk_domain(self._domain_name)
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")
return domain
def _build_lark_client(self, domain: Any) -> Any:
return _build_lark_client(self._app_id, self._app_secret, domain)
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
async def _raw(reply_target: Optional[str]) -> Any:
return await self._send_raw_message(
chat_id=chat_id, msg_type=msg_type, payload=payload, reply_to=reply_target, metadata=metadata,
)
for attempt in range(_FEISHU_SEND_ATTEMPTS):
try:
response = await _raw(active_reply_to)
# Reply target withdrawn/missing → post a new message to the chat instead.
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 _raw(None)
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 (SimpleNamespace fallback when the SDK is unbound) ---
@staticmethod
def _build_get_chat_request(chat_id: str) -> Any:
return _sdk_build(GetChatRequest, chat_id=chat_id)
@staticmethod
def _build_get_message_request(message_id: str) -> Any:
return _sdk_build(GetMessageRequest, message_id=message_id)
@staticmethod
def _build_message_resource_request(*, message_id: str, file_key: str, resource_type: str) -> Any:
return _sdk_build(GetMessageResourceRequest, message_id=message_id, file_key=file_key, type=resource_type)
@staticmethod
def _build_get_application_request(*, app_id: str, lang: str) -> Any:
return _sdk_build(GetApplicationRequest, 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:
return _sdk_build(
ReplyMessageRequestBody,
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:
return _sdk_build(ReplyMessageRequest, message_id=message_id, request_body=request_body)
@staticmethod
def _build_update_message_body(*, msg_type: str, content: str) -> Any:
return _sdk_build(UpdateMessageRequestBody, msg_type=msg_type, content=content)
@staticmethod
def _build_update_message_request(message_id: str, request_body: Any) -> Any:
return _sdk_build(UpdateMessageRequest, message_id=message_id, request_body=request_body)
@staticmethod
def _build_delete_message_request(message_id: str) -> Any:
return _sdk_build(DeleteMessageRequest, message_id=message_id)
@staticmethod
def _build_create_message_body(*, receive_id: str, msg_type: str, content: str, uuid_value: str) -> Any:
return _sdk_build(
CreateMessageRequestBody, 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:
return _sdk_build(CreateMessageRequest, receive_id_type=receive_id_type, request_body=request_body)
@staticmethod
def _build_image_upload_body(*, image_type: str, image: Any) -> Any:
return _sdk_build(CreateImageRequestBody, image_type=image_type, image=image)
@staticmethod
def _build_image_upload_request(request_body: Any) -> Any:
return _sdk_build(CreateImageRequest, request_body=request_body)
@staticmethod
def _build_file_upload_body(*, file_type: str, file_name: str, file: Any, duration: int = 0) -> Any:
if CreateFileRequestBody is None:
return SimpleNamespace(file_type=file_type, file_name=file_name, file=file, duration=duration)
fields: Dict[str, Any] = {"file_type": file_type, "file_name": file_name, "file": file}
if duration > 0:
fields["duration"] = duration
return _sdk_build(CreateFileRequestBody, **fields)
@staticmethod
def _build_file_upload_request(request_body: Any) -> Any:
return _sdk_build(CreateFileRequest, request_body=request_body)
def _build_media_post_payload(self, *, caption: str, media_tag: Dict[str, str]) -> str:
payload = json.loads(_build_markdown_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]:
# requested_message_type is accepted for call-site symmetry; routing is by extension only.
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"
return _FEISHU_FILE_UPLOAD_TYPE, "file"
# --- QR scan-to-create onboarding (device-code flow; Feishu creates a configured bot app) ---
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 data to the registration endpoint; parse JSON even on 4xx (poll's pending is a 400)."""
req = Request(
f"{base_url}{_REGISTRATION_PATH}", data=urlencode(body).encode("utf-8"),
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:
"""Raise RuntimeError unless the registration environment supports client_secret auth."""
res = _post_registration(_accounts_base_url(domain), {"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."""
res = _post_registration(_accounts_base_url(domain), {
"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", "")
qr_url += ("&" if "?" in qr_url else "?") + "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 scan (→ {app_id, app_secret, domain, open_id}), or None on denial/timeout."""
deadline = time.monotonic() + expire_in
current_domain = domain
domain_switched = False
poll_count = 0
while time.monotonic() < deadline:
try:
body = {"action": "poll", "device_code": device_code, "tp": "ob_app"}
res = _post_registration(_accounts_base_url(current_domain), body)
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; fall through — this same response may carry credentials.
user_info = res.get("user_info") or {}
if user_info.get("tenant_brand") == "lark" and not domain_switched:
current_domain = "lark"
domain_switched = True
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"),
}
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
time.sleep(interval) # authorization_pending or unknown — keep polling
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]:
"""Probe /open-apis/bot/v3/info → {"bot_name", "bot_open_id"} (app-scoped open_id, NOT app_id) or None.
Onboarding runs before connect(), so load the SDK here instead of always falling back to HTTP.
"""
if _load_lark_oapi():
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 name ("feishu"/"lark")."""
return _build_lark_client(app_id, app_secret, _sdk_domain(domain))
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:
resp = _build_onboard_client(app_id, app_secret, domain).request(_tenant_get_request("/open-apis/bot/v3/info"))
content = getattr(getattr(resp, "raw", None), "content", None)
return None if content is None else _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)
def _get_json(path: str, *, data: Optional[bytes] = None, extra_headers: Optional[Dict[str, str]] = None) -> dict:
headers = {**(extra_headers or {}), "Content-Type": "application/json"}
with urlopen(Request(f"{base_url}{path}", data=data, headers=headers), timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
return json.loads(resp.read().decode("utf-8"))
try:
token_res = _get_json(
"/open-apis/auth/v3/tenant_access_token/internal",
data=json.dumps({"app_id": app_id, "app_secret": app_secret}).encode("utf-8"),
)
access_token = token_res.get("tenant_access_token")
if not access_token:
return None
bot_res = _get_json("/open-apis/bot/v3/info", extra_headers={"Authorization": f"Bearer {access_token}"})
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]:
"""Scan-to-create flow → {app_id, app_secret, domain, open_id, bot_name, bot_open_id}.
None on expected failures (network, denied, timeout); unexpected errors propagate.
"""
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")
from hermes_cli.managed_uv import pip_install_hint
print(f" Tip: {pip_install_hint('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
bot_info = probe_bot(result["app_id"], result["app_secret"], result["domain"]) or {} # best-effort
result["bot_name"] = bot_info.get("bot_name")
result["bot_open_id"] = bot_info.get("bot_open_id")
return result
# --- Plugin glue: register(ctx) + the hook fns that replaced the per-platform core touchpoints ---
# ────────────────────────────────────────────────────────────────────────── 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", ".webm", ".3gp"}
_MIGRATION_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False):
"""standalone_sender_fn: out-of-process delivery (cron without gateway) via a transient adapter."""
if not await asyncio.to_thread(_load_lark_oapi):
return send_error("Feishu dependencies not installed. Run `hermes setup` to install Feishu support.")
try:
adapter = FeishuAdapter(pconfig)
adapter._client = adapter._build_lark_client(_sdk_domain(getattr(adapter, "_domain_name", "feishu")))
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 send_error(f"Feishu send failed: {last_result.error}")
for media_path, _is_voice in media_files or []:
if not os.path.exists(media_path):
return send_error(f"Media file not found: {media_path}")
ext = os.path.splitext(media_path)[1].lower()
if ext in _MIGRATION_IMAGE_EXTS:
sender = adapter.send_image_file
elif ext in _MIGRATION_VIDEO_EXTS:
sender = adapter.send_video
elif ext in _MIGRATION_AUDIO_EXTS: # voice + non-voice audio both go out as voice
sender = adapter.send_voice
else:
sender = adapter.send_document
last_result = await sender(chat_id, media_path, metadata=metadata)
if not last_result.success:
return send_error(f"Feishu media send failed: {last_result.error}")
if last_result is None:
return send_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 send_error(f"Feishu send failed: {e}")
def interactive_setup() -> None:
"""Interactive setup for Feishu / Lark — scan-to-create or manual creds (CLI helpers lazy-imported)."""
from hermes_cli.config import remove_env_value, save_env_value
from hermes_cli.setup import prompt_choice
from hermes_cli.cli_output import prompt, print_header, print_info, print_success, print_warning
from hermes_cli.setup_platforms import declines_reconfigure
print_header("Feishu / Lark")
if declines_reconfigure("Feishu / Lark", "Reconfigure Feishu / Lark?", "FEISHU_APP_ID"):
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
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 not credentials:
print_info("QR setup did not complete. Continuing with manual input.")
used_qr = bool(credentials)
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 = "lark" if prompt_choice("Domain", ["feishu (China)", "lark (International)"], 0) == 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, app_secret = credentials["app_id"], credentials["app_secret"]
domain = credentials.get("domain", "feishu")
open_id, bot_name = credentials.get("open_id"), 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,
)
save_env_value("FEISHU_ALLOW_ALL_USERS", "true" if access_idx == 1 else "false")
if access_idx == 2:
allowlist = prompt("Allowed user IDs (comma-separated)", open_id or "", password=False).replace(" ", "")
save_env_value("FEISHU_ALLOWED_USERS", allowlist)
print_success("Allowlist saved.")
else:
save_env_value("FEISHU_ALLOWED_USERS", "")
if access_idx == 0:
print_success("DM pairing enabled.")
print_info("Unknown users can request access; approve with `hermes pairing approve`.")
else:
print_warning("Open DM access enabled for Feishu / Lark.")
group_idx = prompt_choice(
"How should group chats be handled?",
["Respond only when @mentioned in groups (recommended)", "Disable group chats"], 0,
)
save_env_value("FEISHU_GROUP_POLICY", "open" if group_idx == 0 else "disabled")
print_info("Group chats enabled (bot must be @mentioned)." if group_idx == 0 else "Group chats disabled.")
print_info("Leave blank to clear a previously saved home channel (cron / notifications).")
home_channel = prompt("Home chat ID (optional, for cron/notifications)", password=False).strip()
if home_channel:
save_env_value("FEISHU_HOME_CHANNEL", home_channel)
print_success(f"Home channel set to {home_channel}")
elif remove_env_value("FEISHU_HOME_CHANNEL"):
print_info("Home channel cleared.")
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:
"""``apply_yaml_config_fn`` (#24849): bridge config.yaml feishu.allow_bots to FEISHU_ALLOW_BOTS (env wins) and
seed ``extra.allow_bots`` so a multiplexed secondary profile's adapter reads its own value."""
seeded = _apply_yaml_bridge(feishu_cfg, (("allow_bots", "FEISHU_ALLOW_BOTS", "lower"),))
return {"allow_bots": str(seeded["allow_bots"]).lower()} if seeded else None
def _is_connected(config) -> bool:
"""Feishu counts as connected once app_id is configured."""
extra = getattr(config, "extra", {}) or {}
return bool(extra.get("app_id"))
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="feishu", label="Feishu / Lark", adapter_factory=FeishuAdapter,
check_fn=feishu_deps_present, ensure_deps_fn=check_feishu_requirements,
is_connected=_is_connected, validate_config=_is_connected,
required_env=["FEISHU_APP_ID", "FEISHU_APP_SECRET"],
install_hint="Run `hermes setup` to install Feishu support.", 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,
)