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