mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
4631 lines
188 KiB
Python
4631 lines
188 KiB
Python
"""
|
|
Feishu/Lark platform adapter.
|
|
|
|
Supports:
|
|
- WebSocket long connection and Webhook transport
|
|
- Direct-message and group @mention-gated text receive/send
|
|
- Inbound image/file/audio/media caching
|
|
- Gateway allowlist integration via FEISHU_ALLOWED_USERS
|
|
- Persistent dedup state across restarts
|
|
- Per-chat serial message processing (matches openclaw createChatQueue)
|
|
- Processing status reactions: Typing while working, removed on success,
|
|
swapped for CrossMark on failure
|
|
- Reaction events routed as synthetic text events (matches openclaw)
|
|
- Interactive card button-click events routed as synthetic COMMAND events
|
|
- Webhook anomaly tracking (matches openclaw createWebhookAnomalyTracker)
|
|
- Verification token validation as second auth layer (matches openclaw)
|
|
|
|
Feishu identity model
|
|
---------------------
|
|
Feishu uses three user-ID tiers (official docs:
|
|
https://open.feishu.cn/document/home/user-identity-introduction/introduction):
|
|
|
|
open_id (ou_xxx) — **App-scoped**. The same person gets a different
|
|
open_id under each Feishu app. Always available in
|
|
event payloads without extra permissions.
|
|
user_id (u_xxx) — **Tenant-scoped**. Stable within a company but
|
|
requires the ``contact:user.employee_id:readonly``
|
|
scope. May not be present.
|
|
union_id (on_xxx) — **Developer-scoped**. Same across all apps owned by
|
|
one developer/ISV. Best cross-app stable ID.
|
|
|
|
For bots specifically:
|
|
|
|
app_id — The application's canonical credential identifier.
|
|
bot open_id — Returned by ``/bot/v3/info``. This is the bot's own
|
|
open_id *within its app context* and is what Feishu
|
|
puts in ``mentions[].id.open_id`` when someone
|
|
@-mentions the bot. Used for mention gating only.
|
|
|
|
In single-bot mode (what Hermes currently supports), open_id works as a
|
|
de-facto unique user identifier since there is only one app context.
|
|
|
|
Session-key participant isolation prefers ``union_id`` (via user_id_alt)
|
|
over ``open_id`` (via user_id) so that sessions stay stable if the same
|
|
user is seen through different apps in the future.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import itertools
|
|
import json
|
|
import logging
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections import OrderedDict
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any, Dict, List, Optional, Sequence
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlencode
|
|
from urllib.request import Request, urlopen
|
|
|
|
# aiohttp/websockets are independent optional deps — import outside lark_oapi
|
|
# so they remain available for tests and webhook mode even if lark_oapi is missing.
|
|
try:
|
|
import aiohttp
|
|
from aiohttp import web
|
|
except ImportError:
|
|
aiohttp = None # type: ignore[assignment]
|
|
web = None # type: ignore[assignment]
|
|
|
|
try:
|
|
import websockets
|
|
except ImportError:
|
|
websockets = None # type: ignore[assignment]
|
|
|
|
try:
|
|
import lark_oapi as lark
|
|
from lark_oapi.api.application.v6 import GetApplicationRequest
|
|
from lark_oapi.api.im.v1 import (
|
|
CreateFileRequest,
|
|
CreateFileRequestBody,
|
|
CreateImageRequest,
|
|
CreateImageRequestBody,
|
|
CreateMessageRequest,
|
|
CreateMessageRequestBody,
|
|
GetChatRequest,
|
|
GetMessageRequest,
|
|
GetMessageResourceRequest,
|
|
P2ImMessageMessageReadV1,
|
|
ReplyMessageRequest,
|
|
ReplyMessageRequestBody,
|
|
UpdateMessageRequest,
|
|
UpdateMessageRequestBody,
|
|
)
|
|
from lark_oapi.core import AccessTokenType, HttpMethod
|
|
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
|
from lark_oapi.core.model import BaseRequest
|
|
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
|
CallBackCard,
|
|
P2CardActionTriggerResponse,
|
|
)
|
|
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
|
|
from lark_oapi.ws import Client as FeishuWSClient
|
|
|
|
FEISHU_AVAILABLE = True
|
|
except ImportError:
|
|
FEISHU_AVAILABLE = False
|
|
lark = None # type: ignore[assignment]
|
|
CallBackCard = None # type: ignore[assignment]
|
|
P2CardActionTriggerResponse = None # type: ignore[assignment]
|
|
EventDispatcherHandler = None # type: ignore[assignment]
|
|
FeishuWSClient = None # type: ignore[assignment]
|
|
FEISHU_DOMAIN = None # type: ignore[assignment]
|
|
LARK_DOMAIN = None # type: ignore[assignment]
|
|
|
|
FEISHU_WEBSOCKET_AVAILABLE = websockets is not None
|
|
FEISHU_WEBHOOK_AVAILABLE = aiohttp is not None
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import (
|
|
BasePlatformAdapter,
|
|
MessageEvent,
|
|
MessageType,
|
|
ProcessingOutcome,
|
|
SendResult,
|
|
SUPPORTED_DOCUMENT_TYPES,
|
|
cache_document_from_bytes,
|
|
cache_image_from_url,
|
|
cache_audio_from_bytes,
|
|
cache_image_from_bytes,
|
|
)
|
|
from gateway.status import acquire_scoped_lock, release_scoped_lock
|
|
from hermes_constants import get_hermes_home
|
|
|
|
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].+?~~)|(<u>.+?</u>)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)",
|
|
re.MULTILINE,
|
|
)
|
|
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
_MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$")
|
|
_MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^```\s*$")
|
|
_MENTION_RE = re.compile(r"@_user_\d+")
|
|
_MULTISPACE_RE = re.compile(r"[ \t]{2,}")
|
|
_POST_CONTENT_INVALID_RE = re.compile(r"content format of the post type is incorrect", re.IGNORECASE)
|
|
# ---------------------------------------------------------------------------
|
|
# Media type sets and upload constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
|
_AUDIO_EXTENSIONS = {".ogg", ".mp3", ".wav", ".m4a", ".aac", ".flac", ".opus", ".webm"}
|
|
_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".3gp"}
|
|
_DOCUMENT_MIME_TO_EXT = {mime: ext for ext, mime in SUPPORTED_DOCUMENT_TYPES.items()}
|
|
_FEISHU_IMAGE_UPLOAD_TYPE = "message"
|
|
_FEISHU_FILE_UPLOAD_TYPE = "stream"
|
|
_FEISHU_OPUS_UPLOAD_EXTENSIONS = {".ogg", ".opus"}
|
|
_FEISHU_MEDIA_UPLOAD_EXTENSIONS = {".mp4", ".mov", ".avi", ".m4v"}
|
|
_FEISHU_DOC_UPLOAD_TYPES = {
|
|
".pdf": "pdf",
|
|
".doc": "doc",
|
|
".docx": "doc",
|
|
".xls": "xls",
|
|
".xlsx": "xls",
|
|
".ppt": "ppt",
|
|
".pptx": "ppt",
|
|
}
|
|
# ---------------------------------------------------------------------------
|
|
# Connection, retry and batching tuning
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_MAX_TEXT_INJECT_BYTES = 100 * 1024
|
|
_FEISHU_CONNECT_ATTEMPTS = 3
|
|
_FEISHU_SEND_ATTEMPTS = 3
|
|
_FEISHU_APP_LOCK_SCOPE = "feishu-app-id"
|
|
_DEFAULT_TEXT_BATCH_DELAY_SECONDS = 0.6
|
|
_DEFAULT_TEXT_BATCH_MAX_MESSAGES = 8
|
|
_DEFAULT_TEXT_BATCH_MAX_CHARS = 4000
|
|
_DEFAULT_MEDIA_BATCH_DELAY_SECONDS = 0.8
|
|
_DEFAULT_DEDUP_CACHE_SIZE = 2048
|
|
_DEFAULT_WEBHOOK_HOST = "127.0.0.1"
|
|
_DEFAULT_WEBHOOK_PORT = 8765
|
|
_DEFAULT_WEBHOOK_PATH = "/feishu/webhook"
|
|
# ---------------------------------------------------------------------------
|
|
# TTL, rate-limit and webhook security constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_FEISHU_DEDUP_TTL_SECONDS = 24 * 60 * 60 # 24 hours — matches openclaw
|
|
_FEISHU_SENDER_NAME_TTL_SECONDS = 10 * 60 # 10 minutes sender-name cache
|
|
_FEISHU_WEBHOOK_MAX_BODY_BYTES = 1 * 1024 * 1024 # 1 MB body limit
|
|
_FEISHU_WEBHOOK_RATE_WINDOW_SECONDS = 60 # sliding window for rate limiter
|
|
_FEISHU_WEBHOOK_RATE_LIMIT_MAX = 120 # max requests per window per IP — matches openclaw
|
|
_FEISHU_WEBHOOK_RATE_MAX_KEYS = 4096 # max tracked keys (prevents unbounded growth)
|
|
_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS = 30 # max seconds to read request body
|
|
_FEISHU_WEBHOOK_ANOMALY_THRESHOLD = 25 # consecutive error responses before WARNING log
|
|
_FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60 # anomaly tracker TTL (6 hours) — matches openclaw
|
|
_FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS = 15 * 60 # card action token dedup window (15 min)
|
|
|
|
_APPROVAL_CHOICE_MAP: Dict[str, str] = {
|
|
"approve_once": "once",
|
|
"approve_session": "session",
|
|
"approve_always": "always",
|
|
"deny": "deny",
|
|
}
|
|
_APPROVAL_LABEL_MAP: Dict[str, str] = {
|
|
"once": "Approved once",
|
|
"session": "Approved for session",
|
|
"always": "Approved permanently",
|
|
"deny": "Denied",
|
|
}
|
|
_FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs
|
|
_FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback
|
|
|
|
# Feishu reactions render as prominent badges, unlike Discord/Telegram's
|
|
# small footer emoji — a success badge on every message would add noise, so
|
|
# we only mark start (Typing) and failure (CrossMark); the reply itself is
|
|
# the success signal.
|
|
_FEISHU_REACTION_IN_PROGRESS = "Typing"
|
|
_FEISHU_REACTION_FAILURE = "CrossMark"
|
|
# Bound on the (message_id → reaction_id) handle cache. Happy-path entries
|
|
# drain on completion; the cap is a safeguard against unbounded growth from
|
|
# delete-failures, not a capacity plan.
|
|
_FEISHU_PROCESSING_REACTION_CACHE_SIZE = 1024
|
|
|
|
# QR onboarding constants
|
|
_ONBOARD_ACCOUNTS_URLS = {
|
|
"feishu": "https://accounts.feishu.cn",
|
|
"lark": "https://accounts.larksuite.com",
|
|
}
|
|
_ONBOARD_OPEN_URLS = {
|
|
"feishu": "https://open.feishu.cn",
|
|
"lark": "https://open.larksuite.com",
|
|
}
|
|
_REGISTRATION_PATH = "/oauth/v1/app/registration"
|
|
_ONBOARD_REQUEST_TIMEOUT_S = 10
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fallback display strings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
FALLBACK_POST_TEXT = "[Rich text message]"
|
|
FALLBACK_FORWARD_TEXT = "[Merged forward message]"
|
|
FALLBACK_SHARE_CHAT_TEXT = "[Shared chat]"
|
|
FALLBACK_INTERACTIVE_TEXT = "[Interactive message]"
|
|
FALLBACK_IMAGE_TEXT = "[Image]"
|
|
FALLBACK_ATTACHMENT_TEXT = "[Attachment]"
|
|
# ---------------------------------------------------------------------------
|
|
# Post/card parsing helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PREFERRED_LOCALES = ("zh_cn", "en_us")
|
|
_MARKDOWN_SPECIAL_CHARS_RE = re.compile(r"([\\`*_{}\[\]()#+\-!|>~])")
|
|
_MENTION_PLACEHOLDER_RE = re.compile(r"@_user_\d+")
|
|
_MENTION_BOUNDARY_CHARS = frozenset(" \t\n\r.,;:!?、,。;:!?()[]{}<>\"'`")
|
|
_TRAILING_TERMINAL_PUNCT = frozenset(" \t\n\r.!?。!?")
|
|
_WHITESPACE_RE = re.compile(r"\s+")
|
|
_SUPPORTED_CARD_TEXT_KEYS = (
|
|
"title",
|
|
"text",
|
|
"content",
|
|
"label",
|
|
"value",
|
|
"name",
|
|
"summary",
|
|
"subtitle",
|
|
"description",
|
|
"placeholder",
|
|
"hint",
|
|
)
|
|
_SKIP_TEXT_KEYS = {
|
|
"tag",
|
|
"type",
|
|
"msg_type",
|
|
"message_type",
|
|
"chat_id",
|
|
"open_chat_id",
|
|
"share_chat_id",
|
|
"file_key",
|
|
"image_key",
|
|
"user_id",
|
|
"open_id",
|
|
"union_id",
|
|
"url",
|
|
"href",
|
|
"link",
|
|
"token",
|
|
"template",
|
|
"locale",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FeishuPostMediaRef:
|
|
file_key: str
|
|
file_name: str = ""
|
|
resource_type: str = "file"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FeishuMentionRef:
|
|
name: str = ""
|
|
open_id: str = ""
|
|
is_all: bool = False
|
|
is_self: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _FeishuBotIdentity:
|
|
open_id: str = ""
|
|
user_id: str = ""
|
|
name: str = ""
|
|
|
|
def matches(self, *, open_id: str, user_id: str, name: str) -> bool:
|
|
# Precedence: open_id > user_id > name. IDs are authoritative when both
|
|
# sides have them; the next tier is only considered when either side
|
|
# lacks the current one.
|
|
if open_id and self.open_id:
|
|
return open_id == self.open_id
|
|
if user_id and self.user_id:
|
|
return user_id == self.user_id
|
|
return bool(self.name) and name == self.name
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FeishuPostParseResult:
|
|
text_content: str
|
|
image_keys: List[str] = field(default_factory=list)
|
|
media_refs: List[FeishuPostMediaRef] = field(default_factory=list)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FeishuNormalizedMessage:
|
|
raw_type: str
|
|
text_content: str
|
|
preferred_message_type: str = "text"
|
|
image_keys: List[str] = field(default_factory=list)
|
|
media_refs: List[FeishuPostMediaRef] = field(default_factory=list)
|
|
mentions: List[FeishuMentionRef] = field(default_factory=list)
|
|
relation_kind: str = "plain"
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FeishuAdapterSettings:
|
|
app_id: str # Canonical bot/app identifier (credential, not from event payloads)
|
|
app_secret: str
|
|
domain_name: str
|
|
connection_mode: str
|
|
encrypt_key: str
|
|
verification_token: str
|
|
group_policy: str
|
|
allowed_group_users: frozenset[str]
|
|
# Bot's own open_id (app-scoped) — returned by /bot/v3/info. Used only for
|
|
# @mention matching: Feishu puts this value in mentions[].id.open_id when
|
|
# a user @-mentions the bot in a group chat.
|
|
bot_open_id: str
|
|
# Bot's user_id (tenant-scoped) — optional, used as fallback mention match.
|
|
bot_user_id: str
|
|
bot_name: str
|
|
dedup_cache_size: int
|
|
text_batch_delay_seconds: float
|
|
text_batch_split_delay_seconds: float
|
|
text_batch_max_messages: int
|
|
text_batch_max_chars: int
|
|
media_batch_delay_seconds: float
|
|
webhook_host: str
|
|
webhook_port: int
|
|
webhook_path: str
|
|
ws_reconnect_nonce: int = 30
|
|
ws_reconnect_interval: int = 120
|
|
ws_ping_interval: Optional[int] = None
|
|
ws_ping_timeout: Optional[int] = None
|
|
admins: frozenset[str] = frozenset()
|
|
default_group_policy: str = ""
|
|
group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class FeishuGroupRule:
|
|
"""Per-group policy rule for controlling which users may interact with the bot."""
|
|
|
|
policy: str # "open" | "allowlist" | "blacklist" | "admin_only" | "disabled"
|
|
allowlist: set[str] = field(default_factory=set)
|
|
blacklist: set[str] = field(default_factory=set)
|
|
|
|
|
|
@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"<u>{rendered}</u>"
|
|
if _is_style_enabled(style_dict, "strikethrough"):
|
|
rendered = f"~~{rendered}~~"
|
|
return rendered
|
|
|
|
|
|
def _render_code_block_element(element: Dict[str, Any]) -> str:
|
|
language = _sanitize_fence_language(
|
|
str(element.get("language", "") or "") or str(element.get("lang", "") or "")
|
|
)
|
|
code = (
|
|
str(element.get("text", "") or "") or str(element.get("content", "") or "")
|
|
).replace("\r\n", "\n")
|
|
trailing_newline = "" if code.endswith("\n") else "\n"
|
|
return f"```{language}\n{code}{trailing_newline}```"
|
|
|
|
|
|
def _strip_markdown_to_plain_text(text: str) -> str:
|
|
"""Strip markdown formatting to plain text for Feishu text fallbacks.
|
|
|
|
Delegates common markdown stripping to the shared helper and adds
|
|
Feishu-specific patterns (blockquotes, strikethrough, underline tags,
|
|
horizontal rules, \\r\\n normalisation).
|
|
"""
|
|
from gateway.platforms.helpers import strip_markdown
|
|
plain = text.replace("\r\n", "\n")
|
|
plain = _MARKDOWN_LINK_RE.sub(lambda m: f"{m.group(1)} ({m.group(2).strip()})", plain)
|
|
plain = re.sub(r"^>\s?", "", plain, flags=re.MULTILINE)
|
|
plain = re.sub(r"^\s*---+\s*$", "---", plain, flags=re.MULTILINE)
|
|
plain = re.sub(r"~~([^~\n]+)~~", r"\1", plain)
|
|
plain = re.sub(r"<u>([\s\S]*?)</u>", r"\1", plain)
|
|
plain = strip_markdown(plain)
|
|
return plain
|
|
|
|
|
|
def _coerce_int(value: Any, default: Optional[int] = None, min_value: int = 0) -> Optional[int]:
|
|
"""Coerce value to int with optional default and minimum constraint."""
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return parsed if parsed >= min_value else default
|
|
|
|
|
|
def _coerce_required_int(value: Any, default: int, min_value: int = 0) -> int:
|
|
parsed = _coerce_int(value, default=default, min_value=min_value)
|
|
return default if parsed is None else parsed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Post payload builders and parsers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_markdown_post_payload(content: str) -> str:
|
|
rows = _build_markdown_post_rows(content)
|
|
return json.dumps(
|
|
{
|
|
"zh_cn": {
|
|
"content": rows,
|
|
}
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
|
|
def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]:
|
|
"""Build Feishu post rows while isolating fenced code blocks.
|
|
|
|
Feishu's `md` renderer can swallow trailing content when a fenced code block
|
|
appears inside one large markdown element. Split the reply at real fence
|
|
lines so prose before/after the code block remains visible while code stays
|
|
in a dedicated row.
|
|
"""
|
|
if not content:
|
|
return [[{"tag": "md", "text": ""}]]
|
|
if "```" not in content:
|
|
return [[{"tag": "md", "text": content}]]
|
|
|
|
rows: List[List[Dict[str, str]]] = []
|
|
current: List[str] = []
|
|
in_code_block = False
|
|
|
|
def _flush_current() -> None:
|
|
nonlocal current
|
|
if not current:
|
|
return
|
|
segment = "\n".join(current)
|
|
if segment.strip():
|
|
rows.append([{"tag": "md", "text": segment}])
|
|
current = []
|
|
|
|
for raw_line in content.splitlines():
|
|
stripped_line = raw_line.strip()
|
|
is_fence = bool(
|
|
_MARKDOWN_FENCE_CLOSE_RE.match(stripped_line)
|
|
if in_code_block
|
|
else _MARKDOWN_FENCE_OPEN_RE.match(stripped_line)
|
|
)
|
|
|
|
if is_fence:
|
|
if not in_code_block:
|
|
_flush_current()
|
|
current.append(raw_line)
|
|
in_code_block = not in_code_block
|
|
if not in_code_block:
|
|
_flush_current()
|
|
continue
|
|
|
|
current.append(raw_line)
|
|
|
|
_flush_current()
|
|
return rows or [[{"tag": "md", "text": content}]]
|
|
|
|
|
|
def parse_feishu_post_payload(
|
|
payload: Any,
|
|
*,
|
|
mentions_map: Optional[Dict[str, FeishuMentionRef]] = None,
|
|
) -> FeishuPostParseResult:
|
|
resolved = _resolve_post_payload(payload)
|
|
if not resolved:
|
|
return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT)
|
|
|
|
image_keys: List[str] = []
|
|
media_refs: List[FeishuPostMediaRef] = []
|
|
parts: List[str] = []
|
|
|
|
title = _normalize_feishu_text(str(resolved.get("title", "")).strip())
|
|
if title:
|
|
parts.append(title)
|
|
|
|
for row in resolved.get("content", []) or []:
|
|
if not isinstance(row, list):
|
|
continue
|
|
row_text = _normalize_feishu_text(
|
|
"".join(
|
|
_render_post_element(item, image_keys, media_refs, mentions_map)
|
|
for item in row
|
|
)
|
|
)
|
|
if row_text:
|
|
parts.append(row_text)
|
|
|
|
return FeishuPostParseResult(
|
|
text_content="\n".join(parts).strip() or FALLBACK_POST_TEXT,
|
|
image_keys=image_keys,
|
|
media_refs=media_refs,
|
|
)
|
|
|
|
|
|
def _resolve_post_payload(payload: Any) -> Dict[str, Any]:
|
|
direct = _to_post_payload(payload)
|
|
if direct:
|
|
return direct
|
|
if not isinstance(payload, dict):
|
|
return {}
|
|
|
|
wrapped = payload.get("post")
|
|
wrapped_direct = _resolve_locale_payload(wrapped)
|
|
if wrapped_direct:
|
|
return wrapped_direct
|
|
return _resolve_locale_payload(payload)
|
|
|
|
|
|
def _resolve_locale_payload(payload: Any) -> Dict[str, Any]:
|
|
direct = _to_post_payload(payload)
|
|
if direct:
|
|
return direct
|
|
if not isinstance(payload, dict):
|
|
return {}
|
|
|
|
for key in _PREFERRED_LOCALES:
|
|
candidate = _to_post_payload(payload.get(key))
|
|
if candidate:
|
|
return candidate
|
|
for value in payload.values():
|
|
candidate = _to_post_payload(value)
|
|
if candidate:
|
|
return candidate
|
|
return {}
|
|
|
|
|
|
def _to_post_payload(candidate: Any) -> Dict[str, Any]:
|
|
if not isinstance(candidate, dict):
|
|
return {}
|
|
content = candidate.get("content")
|
|
if not isinstance(content, list):
|
|
return {}
|
|
return {
|
|
"title": str(candidate.get("title", "") or ""),
|
|
"content": content,
|
|
}
|
|
|
|
|
|
def _render_post_element(
|
|
element: Any,
|
|
image_keys: List[str],
|
|
media_refs: List[FeishuPostMediaRef],
|
|
mentions_map: Optional[Dict[str, FeishuMentionRef]] = None,
|
|
) -> str:
|
|
if isinstance(element, str):
|
|
return element
|
|
if not isinstance(element, dict):
|
|
return ""
|
|
|
|
tag = str(element.get("tag", "")).strip().lower()
|
|
if tag == "text":
|
|
return _render_text_element(element)
|
|
if tag == "a":
|
|
href = str(element.get("href", "")).strip()
|
|
label = str(element.get("text", href) or "").strip()
|
|
if not label:
|
|
return ""
|
|
escaped_label = _escape_markdown_text(label)
|
|
return f"[{escaped_label}]({href})" if href else escaped_label
|
|
if tag == "at":
|
|
# Post <at>.user_id is a placeholder ("@_user_N" or "@_all"); look up
|
|
# the real ref in mentions_map for the display name.
|
|
placeholder = str(element.get("user_id", "")).strip()
|
|
if placeholder == "@_all":
|
|
# Feishu SDK sometimes omits @_all from the top-level mentions
|
|
# payload; record it here so the caller's mention list stays complete.
|
|
if mentions_map is not None and "@_all" not in mentions_map:
|
|
mentions_map["@_all"] = FeishuMentionRef(is_all=True)
|
|
return "@all"
|
|
ref = (mentions_map or {}).get(placeholder)
|
|
if ref is not None:
|
|
display_name = ref.name or ref.open_id or "user"
|
|
else:
|
|
display_name = str(element.get("user_name", "")).strip() or "user"
|
|
return f"@{_escape_markdown_text(display_name)}"
|
|
if tag in {"img", "image"}:
|
|
image_key = str(element.get("image_key", "")).strip()
|
|
if image_key and image_key not in image_keys:
|
|
image_keys.append(image_key)
|
|
alt = str(element.get("text", "")).strip() or str(element.get("alt", "")).strip()
|
|
return f"[Image: {alt}]" if alt else "[Image]"
|
|
if tag in {"media", "file", "audio", "video"}:
|
|
file_key = str(element.get("file_key", "")).strip()
|
|
file_name = (
|
|
str(element.get("file_name", "")).strip()
|
|
or str(element.get("title", "")).strip()
|
|
or str(element.get("text", "")).strip()
|
|
)
|
|
if file_key:
|
|
media_refs.append(
|
|
FeishuPostMediaRef(
|
|
file_key=file_key,
|
|
file_name=file_name,
|
|
resource_type=tag if tag in {"audio", "video"} else "file",
|
|
)
|
|
)
|
|
return f"[Attachment: {file_name}]" if file_name else "[Attachment]"
|
|
if tag in {"emotion", "emoji"}:
|
|
label = str(element.get("text", "")).strip() or str(element.get("emoji_type", "")).strip()
|
|
return f":{_escape_markdown_text(label)}:" if label else "[Emoji]"
|
|
if tag == "br":
|
|
return "\n"
|
|
if tag in {"hr", "divider"}:
|
|
return "\n\n---\n\n"
|
|
if tag == "code":
|
|
code = str(element.get("text", "") or "") or str(element.get("content", "") or "")
|
|
return _wrap_inline_code(code) if code else ""
|
|
if tag in {"code_block", "pre"}:
|
|
return _render_code_block_element(element)
|
|
|
|
nested_parts: List[str] = []
|
|
for key in ("text", "title", "content", "children", "elements"):
|
|
extracted = _render_nested_post(element.get(key), image_keys, media_refs, mentions_map)
|
|
if extracted:
|
|
nested_parts.append(extracted)
|
|
return " ".join(part for part in nested_parts if part)
|
|
|
|
|
|
def _render_nested_post(
|
|
value: Any,
|
|
image_keys: List[str],
|
|
media_refs: List[FeishuPostMediaRef],
|
|
mentions_map: Optional[Dict[str, FeishuMentionRef]] = None,
|
|
) -> str:
|
|
if isinstance(value, str):
|
|
return _escape_markdown_text(value)
|
|
if isinstance(value, list):
|
|
return " ".join(
|
|
part
|
|
for item in value
|
|
for part in [_render_nested_post(item, image_keys, media_refs, mentions_map)]
|
|
if part
|
|
)
|
|
if isinstance(value, dict):
|
|
direct = _render_post_element(value, image_keys, media_refs, mentions_map)
|
|
if direct:
|
|
return direct
|
|
return " ".join(
|
|
part
|
|
for item in value.values()
|
|
for part in [_render_nested_post(item, image_keys, media_refs, mentions_map)]
|
|
if part
|
|
)
|
|
return ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Message normalization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def normalize_feishu_message(
|
|
*,
|
|
message_type: str,
|
|
raw_content: str,
|
|
mentions: Optional[Sequence[Any]] = None,
|
|
bot: _FeishuBotIdentity = _FeishuBotIdentity(),
|
|
) -> FeishuNormalizedMessage:
|
|
normalized_type = str(message_type or "").strip().lower()
|
|
payload = _load_feishu_payload(raw_content)
|
|
mentions_map = _build_mentions_map(mentions, bot)
|
|
|
|
if normalized_type == "text":
|
|
text = str(payload.get("text", "") or "")
|
|
# Feishu SDK sometimes omits @_all from the mentions payload even when
|
|
# the text literal contains it (confirmed via im.v1.message.get).
|
|
if "@_all" in text and "@_all" not in mentions_map:
|
|
mentions_map["@_all"] = FeishuMentionRef(is_all=True)
|
|
return FeishuNormalizedMessage(
|
|
raw_type=normalized_type,
|
|
text_content=_normalize_feishu_text(text, mentions_map),
|
|
mentions=list(mentions_map.values()),
|
|
)
|
|
if normalized_type == "post":
|
|
# The walker writes back to mentions_map if it encounters
|
|
# <at user_id="@_all">, so reading .values() after parsing is enough.
|
|
parsed_post = parse_feishu_post_payload(payload, mentions_map=mentions_map)
|
|
return FeishuNormalizedMessage(
|
|
raw_type=normalized_type,
|
|
text_content=parsed_post.text_content,
|
|
image_keys=list(parsed_post.image_keys),
|
|
media_refs=list(parsed_post.media_refs),
|
|
mentions=list(mentions_map.values()),
|
|
relation_kind="post",
|
|
)
|
|
mention_refs = list(mentions_map.values())
|
|
if normalized_type == "image":
|
|
image_key = str(payload.get("image_key", "") or "").strip()
|
|
alt_text = _normalize_feishu_text(
|
|
str(payload.get("text", "") or "")
|
|
or str(payload.get("alt", "") or "")
|
|
or FALLBACK_IMAGE_TEXT,
|
|
mentions_map,
|
|
)
|
|
return FeishuNormalizedMessage(
|
|
raw_type=normalized_type,
|
|
text_content=alt_text if alt_text != FALLBACK_IMAGE_TEXT else "",
|
|
preferred_message_type="photo",
|
|
image_keys=[image_key] if image_key else [],
|
|
relation_kind="image",
|
|
mentions=mention_refs,
|
|
)
|
|
if normalized_type in {"file", "audio", "media"}:
|
|
media_ref = _build_media_ref_from_payload(payload, resource_type=normalized_type)
|
|
placeholder = _attachment_placeholder(media_ref.file_name)
|
|
return FeishuNormalizedMessage(
|
|
raw_type=normalized_type,
|
|
text_content="",
|
|
preferred_message_type="audio" if normalized_type == "audio" else "document",
|
|
media_refs=[media_ref] if media_ref.file_key else [],
|
|
relation_kind=normalized_type,
|
|
metadata={"placeholder_text": placeholder},
|
|
mentions=mention_refs,
|
|
)
|
|
if normalized_type == "merge_forward":
|
|
return _normalize_merge_forward_message(payload)
|
|
if normalized_type == "share_chat":
|
|
return _normalize_share_chat_message(payload)
|
|
if normalized_type in {"interactive", "card"}:
|
|
return _normalize_interactive_message(normalized_type, payload)
|
|
|
|
return FeishuNormalizedMessage(raw_type=normalized_type, text_content="")
|
|
|
|
|
|
def _load_feishu_payload(raw_content: str) -> Dict[str, Any]:
|
|
try:
|
|
parsed = json.loads(raw_content) if raw_content else {}
|
|
except json.JSONDecodeError:
|
|
return {"text": raw_content}
|
|
return parsed if isinstance(parsed, dict) else {"content": parsed}
|
|
|
|
|
|
def _normalize_merge_forward_message(payload: Dict[str, Any]) -> FeishuNormalizedMessage:
|
|
title = _first_non_empty_text(
|
|
payload.get("title"),
|
|
payload.get("summary"),
|
|
payload.get("preview"),
|
|
_find_first_text(payload, keys=("title", "summary", "preview", "description")),
|
|
)
|
|
entries = _collect_forward_entries(payload)
|
|
lines: List[str] = []
|
|
if title:
|
|
lines.append(title)
|
|
lines.extend(entries[:8])
|
|
text_content = "\n".join(lines).strip() or FALLBACK_FORWARD_TEXT
|
|
return FeishuNormalizedMessage(
|
|
raw_type="merge_forward",
|
|
text_content=text_content,
|
|
relation_kind="merge_forward",
|
|
metadata={"entry_count": len(entries), "title": title},
|
|
)
|
|
|
|
|
|
def _normalize_share_chat_message(payload: Dict[str, Any]) -> FeishuNormalizedMessage:
|
|
chat_name = _first_non_empty_text(
|
|
payload.get("chat_name"),
|
|
payload.get("name"),
|
|
payload.get("title"),
|
|
_find_first_text(payload, keys=("chat_name", "name", "title")),
|
|
)
|
|
share_id = _first_non_empty_text(
|
|
payload.get("chat_id"),
|
|
payload.get("open_chat_id"),
|
|
payload.get("share_chat_id"),
|
|
)
|
|
lines = []
|
|
if chat_name:
|
|
lines.append(f"Shared chat: {chat_name}")
|
|
else:
|
|
lines.append(FALLBACK_SHARE_CHAT_TEXT)
|
|
if share_id:
|
|
lines.append(f"Chat ID: {share_id}")
|
|
text_content = "\n".join(lines)
|
|
return FeishuNormalizedMessage(
|
|
raw_type="share_chat",
|
|
text_content=text_content,
|
|
relation_kind="share_chat",
|
|
metadata={"chat_id": share_id, "chat_name": chat_name},
|
|
)
|
|
|
|
|
|
def _normalize_interactive_message(message_type: str, payload: Dict[str, Any]) -> FeishuNormalizedMessage:
|
|
card_payload = payload.get("card") if isinstance(payload.get("card"), dict) else payload
|
|
title = _first_non_empty_text(
|
|
_find_header_title(card_payload),
|
|
payload.get("title"),
|
|
_find_first_text(card_payload, keys=("title", "summary", "subtitle")),
|
|
)
|
|
body_lines = _collect_card_lines(card_payload)
|
|
actions = _collect_action_labels(card_payload)
|
|
|
|
lines: List[str] = []
|
|
if title:
|
|
lines.append(title)
|
|
for line in body_lines:
|
|
if line != title:
|
|
lines.append(line)
|
|
if actions:
|
|
lines.append(f"Actions: {', '.join(actions)}")
|
|
|
|
text_content = "\n".join(lines[:12]).strip() or FALLBACK_INTERACTIVE_TEXT
|
|
return FeishuNormalizedMessage(
|
|
raw_type=message_type,
|
|
text_content=text_content,
|
|
relation_kind="interactive",
|
|
metadata={"title": title, "actions": actions},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Content extraction utilities (card / forward / text walking)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _collect_forward_entries(payload: Dict[str, Any]) -> List[str]:
|
|
candidates: List[Any] = []
|
|
for key in ("messages", "items", "message_list", "records", "content"):
|
|
value = payload.get(key)
|
|
if isinstance(value, list):
|
|
candidates.extend(value)
|
|
entries: List[str] = []
|
|
for item in candidates:
|
|
if not isinstance(item, dict):
|
|
text = _normalize_feishu_text(str(item or ""))
|
|
if text:
|
|
entries.append(f"- {text}")
|
|
continue
|
|
sender = _first_non_empty_text(
|
|
item.get("sender_name"),
|
|
item.get("user_name"),
|
|
item.get("sender"),
|
|
item.get("name"),
|
|
)
|
|
nested_type = str(item.get("message_type", "") or item.get("msg_type", "")).strip().lower()
|
|
if nested_type == "post":
|
|
body = parse_feishu_post_payload(item.get("content") or item).text_content
|
|
else:
|
|
body = _first_non_empty_text(
|
|
item.get("text"),
|
|
item.get("summary"),
|
|
item.get("preview"),
|
|
item.get("content"),
|
|
_find_first_text(item, keys=("text", "content", "summary", "preview", "title")),
|
|
)
|
|
body = _normalize_feishu_text(body)
|
|
if sender and body:
|
|
entries.append(f"- {sender}: {body}")
|
|
elif body:
|
|
entries.append(f"- {body}")
|
|
return _unique_lines(entries)
|
|
|
|
|
|
def _collect_card_lines(payload: Any) -> List[str]:
|
|
lines = _collect_text_segments(payload, in_rich_block=False)
|
|
normalized = [_normalize_feishu_text(line) for line in lines]
|
|
return _unique_lines([line for line in normalized if line])
|
|
|
|
|
|
def _collect_action_labels(payload: Any) -> List[str]:
|
|
labels: List[str] = []
|
|
for item in _walk_nodes(payload):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
tag = str(item.get("tag", "") or item.get("type", "")).strip().lower()
|
|
if tag not in {"button", "select_static", "overflow", "date_picker", "picker"}:
|
|
continue
|
|
label = _first_non_empty_text(
|
|
item.get("text"),
|
|
item.get("name"),
|
|
item.get("value"),
|
|
_find_first_text(item, keys=("text", "content", "name", "value")),
|
|
)
|
|
if label:
|
|
labels.append(label)
|
|
return _unique_lines(labels)
|
|
|
|
|
|
def _collect_text_segments(value: Any, *, in_rich_block: bool) -> List[str]:
|
|
if isinstance(value, str):
|
|
return [_normalize_feishu_text(value)] if in_rich_block else []
|
|
if isinstance(value, list):
|
|
segments: List[str] = []
|
|
for item in value:
|
|
segments.extend(_collect_text_segments(item, in_rich_block=in_rich_block))
|
|
return segments
|
|
if not isinstance(value, dict):
|
|
return []
|
|
|
|
tag = str(value.get("tag", "") or value.get("type", "")).strip().lower()
|
|
next_in_rich_block = in_rich_block or tag in {
|
|
"plain_text",
|
|
"lark_md",
|
|
"markdown",
|
|
"note",
|
|
"div",
|
|
"column_set",
|
|
"column",
|
|
"action",
|
|
"button",
|
|
"select_static",
|
|
"date_picker",
|
|
}
|
|
|
|
segments: List[str] = []
|
|
for key in _SUPPORTED_CARD_TEXT_KEYS:
|
|
item = value.get(key)
|
|
if isinstance(item, str) and next_in_rich_block:
|
|
normalized = _normalize_feishu_text(item)
|
|
if normalized:
|
|
segments.append(normalized)
|
|
|
|
for key, item in value.items():
|
|
if key in _SKIP_TEXT_KEYS:
|
|
continue
|
|
segments.extend(_collect_text_segments(item, in_rich_block=next_in_rich_block))
|
|
return segments
|
|
|
|
|
|
def _build_media_ref_from_payload(payload: Dict[str, Any], *, resource_type: str) -> FeishuPostMediaRef:
|
|
file_key = str(payload.get("file_key", "") or "").strip()
|
|
file_name = _first_non_empty_text(
|
|
payload.get("file_name"),
|
|
payload.get("title"),
|
|
payload.get("text"),
|
|
)
|
|
effective_type = resource_type if resource_type in {"audio", "video"} else "file"
|
|
return FeishuPostMediaRef(file_key=file_key, file_name=file_name, resource_type=effective_type)
|
|
|
|
|
|
def _attachment_placeholder(file_name: str) -> str:
|
|
normalized_name = _normalize_feishu_text(file_name)
|
|
return f"[Attachment: {normalized_name}]" if normalized_name else FALLBACK_ATTACHMENT_TEXT
|
|
|
|
|
|
def _find_header_title(payload: Any) -> str:
|
|
if not isinstance(payload, dict):
|
|
return ""
|
|
header = payload.get("header")
|
|
if not isinstance(header, dict):
|
|
return ""
|
|
title = header.get("title")
|
|
if isinstance(title, dict):
|
|
return _first_non_empty_text(title.get("content"), title.get("text"), title.get("name"))
|
|
return _normalize_feishu_text(str(title or ""))
|
|
|
|
|
|
def _find_first_text(payload: Any, *, keys: tuple[str, ...]) -> str:
|
|
for node in _walk_nodes(payload):
|
|
if not isinstance(node, dict):
|
|
continue
|
|
for key in keys:
|
|
value = node.get(key)
|
|
if isinstance(value, str):
|
|
normalized = _normalize_feishu_text(value)
|
|
if normalized:
|
|
return normalized
|
|
return ""
|
|
|
|
|
|
def _walk_nodes(value: Any):
|
|
if isinstance(value, dict):
|
|
yield value
|
|
for item in value.values():
|
|
yield from _walk_nodes(item)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
yield from _walk_nodes(item)
|
|
|
|
|
|
def _first_non_empty_text(*values: Any) -> str:
|
|
for value in values:
|
|
if isinstance(value, str):
|
|
normalized = _normalize_feishu_text(value)
|
|
if normalized:
|
|
return normalized
|
|
elif value is not None and not isinstance(value, (dict, list)):
|
|
normalized = _normalize_feishu_text(str(value))
|
|
if normalized:
|
|
return normalized
|
|
return ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# General text utilities
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _normalize_feishu_text(
|
|
text: str,
|
|
mentions_map: Optional[Dict[str, FeishuMentionRef]] = None,
|
|
) -> str:
|
|
def _sub(match: "re.Match[str]") -> str:
|
|
key = match.group(0)
|
|
ref = (mentions_map or {}).get(key)
|
|
if ref is None:
|
|
return " "
|
|
name = ref.name or ref.open_id or "user"
|
|
return f"@{name}"
|
|
|
|
cleaned = _MENTION_PLACEHOLDER_RE.sub(_sub, text or "")
|
|
cleaned = cleaned.replace("@_all", "@all")
|
|
cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n")
|
|
cleaned = "\n".join(_WHITESPACE_RE.sub(" ", line).strip() for line in cleaned.split("\n"))
|
|
cleaned = "\n".join(line for line in cleaned.split("\n") if line)
|
|
cleaned = _MULTISPACE_RE.sub(" ", cleaned)
|
|
return cleaned.strip()
|
|
|
|
|
|
def _unique_lines(lines: List[str]) -> List[str]:
|
|
seen: set[str] = set()
|
|
unique: List[str] = []
|
|
for line in lines:
|
|
if not line or line in seen:
|
|
continue
|
|
seen.add(line)
|
|
unique.append(line)
|
|
return unique
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mention helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _extract_mention_ids(mention: Any) -> tuple[str, str]:
|
|
# Returns (open_id, user_id). im.v1.message.get hands back id as a string
|
|
# plus id_type discriminator; event payloads hand back a nested UserId
|
|
# object carrying both fields.
|
|
mention_id = getattr(mention, "id", None)
|
|
if isinstance(mention_id, str):
|
|
id_type = str(getattr(mention, "id_type", "") or "").lower()
|
|
if id_type == "open_id":
|
|
return mention_id, ""
|
|
if id_type == "user_id":
|
|
return "", mention_id
|
|
return "", ""
|
|
if mention_id is None:
|
|
return "", ""
|
|
return (
|
|
str(getattr(mention_id, "open_id", "") or ""),
|
|
str(getattr(mention_id, "user_id", "") or ""),
|
|
)
|
|
|
|
|
|
def _build_mentions_map(
|
|
mentions: Optional[Sequence[Any]],
|
|
bot: _FeishuBotIdentity,
|
|
) -> Dict[str, FeishuMentionRef]:
|
|
result: Dict[str, FeishuMentionRef] = {}
|
|
for mention in mentions or []:
|
|
key = str(getattr(mention, "key", "") or "")
|
|
if not key:
|
|
continue
|
|
if key == "@_all":
|
|
result[key] = FeishuMentionRef(is_all=True)
|
|
continue
|
|
open_id, user_id = _extract_mention_ids(mention)
|
|
name = str(getattr(mention, "name", "") or "").strip()
|
|
result[key] = FeishuMentionRef(
|
|
name=name,
|
|
open_id=open_id,
|
|
is_self=bot.matches(open_id=open_id, user_id=user_id, name=name),
|
|
)
|
|
return result
|
|
|
|
|
|
def _build_mention_hint(mentions: Sequence[FeishuMentionRef]) -> str:
|
|
parts: List[str] = []
|
|
seen: set = set()
|
|
for ref in mentions:
|
|
if ref.is_self:
|
|
continue
|
|
signature = (ref.is_all, ref.open_id, ref.name)
|
|
if signature in seen:
|
|
continue
|
|
seen.add(signature)
|
|
if ref.is_all:
|
|
parts.append("@all")
|
|
elif ref.open_id:
|
|
parts.append(f"{ref.name or 'unknown'} (open_id={ref.open_id})")
|
|
else:
|
|
parts.append(ref.name or "unknown")
|
|
return f"[Mentioned: {', '.join(parts)}]" if parts else ""
|
|
|
|
|
|
def _strip_edge_self_mentions(
|
|
text: str,
|
|
mentions: Sequence[FeishuMentionRef],
|
|
) -> str:
|
|
# Leading: strip consecutive self-mentions unconditionally.
|
|
# Trailing: strip only when followed by whitespace/terminal punct, so
|
|
# mid-sentence references ("don't @Bot again") stay intact.
|
|
# Leading word-boundary prevents @Al from eating @Alice.
|
|
if not text:
|
|
return text
|
|
self_names = [
|
|
f"@{ref.name or ref.open_id or 'user'}"
|
|
for ref in mentions
|
|
if ref.is_self
|
|
]
|
|
if not self_names:
|
|
return text
|
|
|
|
remaining = text.lstrip()
|
|
while True:
|
|
for nm in self_names:
|
|
if not remaining.startswith(nm):
|
|
continue
|
|
after = remaining[len(nm):]
|
|
if after and after[0] not in _MENTION_BOUNDARY_CHARS:
|
|
continue
|
|
remaining = after.lstrip()
|
|
break
|
|
else:
|
|
break
|
|
|
|
while True:
|
|
i = len(remaining)
|
|
while i > 0 and remaining[i - 1] in _TRAILING_TERMINAL_PUNCT:
|
|
i -= 1
|
|
body = remaining[:i]
|
|
tail = remaining[i:]
|
|
for nm in self_names:
|
|
if body.endswith(nm):
|
|
remaining = body[: -len(nm)].rstrip() + tail
|
|
break
|
|
else:
|
|
return remaining
|
|
|
|
|
|
def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None:
|
|
"""Run the official Lark WS client in its own thread-local event loop."""
|
|
import lark_oapi.ws.client as ws_client_module
|
|
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
ws_client_module.loop = loop
|
|
adapter._ws_thread_loop = loop
|
|
|
|
original_connect = ws_client_module.websockets.connect
|
|
original_configure = getattr(ws_client, "_configure", None)
|
|
|
|
def _apply_runtime_ws_overrides() -> None:
|
|
try:
|
|
setattr(ws_client, "_reconnect_nonce", adapter._ws_reconnect_nonce)
|
|
setattr(ws_client, "_reconnect_interval", adapter._ws_reconnect_interval)
|
|
if adapter._ws_ping_interval is not None:
|
|
setattr(ws_client, "_ping_interval", adapter._ws_ping_interval)
|
|
except Exception:
|
|
logger.debug("[Feishu] Failed to apply websocket runtime overrides", exc_info=True)
|
|
|
|
async def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any:
|
|
if adapter._ws_ping_interval is not None and "ping_interval" not in kwargs:
|
|
kwargs["ping_interval"] = adapter._ws_ping_interval
|
|
if adapter._ws_ping_timeout is not None and "ping_timeout" not in kwargs:
|
|
kwargs["ping_timeout"] = adapter._ws_ping_timeout
|
|
return await original_connect(*args, **kwargs)
|
|
|
|
def _configure_with_overrides(conf: Any) -> Any:
|
|
if original_configure is None:
|
|
raise RuntimeError("Feishu _configure_with_overrides called but original_configure is None")
|
|
result = original_configure(conf)
|
|
_apply_runtime_ws_overrides()
|
|
return result
|
|
|
|
ws_client_module.websockets.connect = _connect_with_overrides
|
|
if original_configure is not None:
|
|
setattr(ws_client, "_configure", _configure_with_overrides)
|
|
_apply_runtime_ws_overrides()
|
|
try:
|
|
ws_client.start()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
ws_client_module.websockets.connect = original_connect
|
|
if original_configure is not None:
|
|
setattr(ws_client, "_configure", original_configure)
|
|
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
|
|
for task in pending:
|
|
task.cancel()
|
|
if pending:
|
|
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
|
try:
|
|
loop.stop()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
loop.close()
|
|
except Exception:
|
|
pass
|
|
adapter._ws_thread_loop = None
|
|
|
|
|
|
def check_feishu_requirements() -> bool:
|
|
"""Check if Feishu/Lark dependencies are available."""
|
|
return FEISHU_AVAILABLE
|
|
|
|
|
|
class FeishuAdapter(BasePlatformAdapter):
|
|
"""Feishu/Lark bot adapter."""
|
|
|
|
MAX_MESSAGE_LENGTH = 8000
|
|
# Threshold for detecting Feishu client-side message splits.
|
|
# When a chunk is near the ~4096-char practical limit, a continuation
|
|
# is almost certain.
|
|
_SPLIT_THRESHOLD = 4000
|
|
|
|
# =========================================================================
|
|
# Lifecycle — init / settings / connect / disconnect
|
|
# =========================================================================
|
|
|
|
def __init__(self, config: PlatformConfig):
|
|
super().__init__(config, Platform.FEISHU)
|
|
|
|
self._settings = self._load_settings(config.extra or {})
|
|
self._apply_settings(self._settings)
|
|
self._client: Optional[Any] = None
|
|
self._ws_client: Optional[Any] = None
|
|
self._ws_future: Optional[asyncio.Future] = None
|
|
self._ws_thread_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
self._webhook_runner: Optional[Any] = None
|
|
self._webhook_site: Optional[Any] = None
|
|
self._event_handler: Optional[Any] = None
|
|
self._seen_message_ids: Dict[str, float] = {} # message_id → seen_at (time.time())
|
|
self._seen_message_order: List[str] = []
|
|
self._dedup_state_path = get_hermes_home() / "feishu_seen_message_ids.json"
|
|
self._dedup_lock = threading.Lock()
|
|
self._sender_name_cache: Dict[str, tuple[str, float]] = {} # sender_id → (name, expire_at)
|
|
self._webhook_rate_counts: Dict[str, tuple[int, float]] = {} # rate_key → (count, window_start)
|
|
self._webhook_anomaly_counts: Dict[str, tuple[int, str, float]] = {} # ip → (count, last_status, first_seen)
|
|
self._card_action_tokens: Dict[str, float] = {} # token → first_seen_time
|
|
# Inbound events that arrived before the adapter loop was ready
|
|
# (e.g. during startup/restart or network-flap reconnect). A single
|
|
# drainer thread replays them as soon as the loop becomes available.
|
|
self._pending_inbound_events: List[Any] = []
|
|
self._pending_inbound_lock = threading.Lock()
|
|
self._pending_drain_scheduled = False
|
|
self._pending_inbound_max_depth = 1000 # cap queue; drop oldest beyond
|
|
self._chat_locks: 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
|
|
# Exec approval button state (approval_id → {session_key, message_id, chat_id})
|
|
self._approval_state: Dict[int, Dict[str, str]] = {}
|
|
self._approval_counter = itertools.count(1)
|
|
# Feishu reaction deletion requires the opaque reaction_id returned
|
|
# by create, so we cache it per message_id.
|
|
self._pending_processing_reactions: "OrderedDict[str, str]" = OrderedDict()
|
|
self._load_seen_message_ids()
|
|
|
|
@staticmethod
|
|
def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:
|
|
# Parse per-group rules from config
|
|
raw_group_rules = extra.get("group_rules", {})
|
|
group_rules: Dict[str, FeishuGroupRule] = {}
|
|
if isinstance(raw_group_rules, dict):
|
|
for chat_id, rule_cfg in raw_group_rules.items():
|
|
if not isinstance(rule_cfg, dict):
|
|
continue
|
|
group_rules[str(chat_id)] = FeishuGroupRule(
|
|
policy=str(rule_cfg.get("policy", "open")).strip().lower(),
|
|
allowlist=set(str(u).strip() for u in rule_cfg.get("allowlist", []) if str(u).strip()),
|
|
blacklist=set(str(u).strip() for u in rule_cfg.get("blacklist", []) if str(u).strip()),
|
|
)
|
|
|
|
# Bot-level admins
|
|
raw_admins = extra.get("admins", [])
|
|
admins = frozenset(str(u).strip() for u in raw_admins if str(u).strip())
|
|
|
|
# Default group policy (for groups not in group_rules)
|
|
default_group_policy = str(extra.get("default_group_policy", "")).strip().lower()
|
|
|
|
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_split_delay_seconds=float(
|
|
os.getenv("HERMES_FEISHU_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0")
|
|
),
|
|
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
|
|
),
|
|
ws_reconnect_nonce=_coerce_required_int(extra.get("ws_reconnect_nonce"), default=30, min_value=0),
|
|
ws_reconnect_interval=_coerce_required_int(extra.get("ws_reconnect_interval"), default=120, min_value=1),
|
|
ws_ping_interval=_coerce_int(extra.get("ws_ping_interval"), default=None, min_value=1),
|
|
ws_ping_timeout=_coerce_int(extra.get("ws_ping_timeout"), default=None, min_value=1),
|
|
admins=admins,
|
|
default_group_policy=default_group_policy,
|
|
group_rules=group_rules,
|
|
)
|
|
|
|
def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
|
|
self._app_id = settings.app_id
|
|
self._app_secret = settings.app_secret
|
|
self._domain_name = settings.domain_name
|
|
self._connection_mode = settings.connection_mode
|
|
self._encrypt_key = settings.encrypt_key
|
|
self._verification_token = settings.verification_token
|
|
self._group_policy = settings.group_policy
|
|
self._allowed_group_users = set(settings.allowed_group_users)
|
|
self._admins = set(settings.admins)
|
|
self._default_group_policy = settings.default_group_policy or settings.group_policy
|
|
self._group_rules = settings.group_rules
|
|
self._bot_open_id = settings.bot_open_id
|
|
self._bot_user_id = settings.bot_user_id
|
|
self._bot_name = settings.bot_name
|
|
self._dedup_cache_size = settings.dedup_cache_size
|
|
self._text_batch_delay_seconds = settings.text_batch_delay_seconds
|
|
self._text_batch_split_delay_seconds = settings.text_batch_split_delay_seconds
|
|
self._text_batch_max_messages = settings.text_batch_max_messages
|
|
self._text_batch_max_chars = settings.text_batch_max_chars
|
|
self._media_batch_delay_seconds = settings.media_batch_delay_seconds
|
|
self._webhook_host = settings.webhook_host
|
|
self._webhook_port = settings.webhook_port
|
|
self._webhook_path = settings.webhook_path
|
|
self._ws_reconnect_nonce = settings.ws_reconnect_nonce
|
|
self._ws_reconnect_interval = settings.ws_reconnect_interval
|
|
self._ws_ping_interval = settings.ws_ping_interval
|
|
self._ws_ping_timeout = settings.ws_ping_timeout
|
|
|
|
def _build_event_handler(self) -> Any:
|
|
if EventDispatcherHandler is None:
|
|
return None
|
|
return (
|
|
EventDispatcherHandler.builder(
|
|
self._encrypt_key,
|
|
self._verification_token,
|
|
)
|
|
.register_p2_im_message_message_read_v1(self._on_message_read_event)
|
|
.register_p2_im_message_receive_v1(self._on_message_event)
|
|
.register_p2_im_message_reaction_created_v1(
|
|
lambda data: self._on_reaction_event("im.message.reaction.created_v1", data)
|
|
)
|
|
.register_p2_im_message_reaction_deleted_v1(
|
|
lambda data: self._on_reaction_event("im.message.reaction.deleted_v1", data)
|
|
)
|
|
.register_p2_card_action_trigger(self._on_card_action_trigger)
|
|
.register_p2_im_chat_member_bot_added_v1(self._on_bot_added_to_chat)
|
|
.register_p2_im_chat_member_bot_deleted_v1(self._on_bot_removed_from_chat)
|
|
.register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(self._on_p2p_chat_entered)
|
|
.register_p2_im_message_recalled_v1(self._on_message_recalled)
|
|
.register_p2_customized_event(
|
|
"drive.notice.comment_add_v1",
|
|
self._on_drive_comment_event,
|
|
)
|
|
.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()
|
|
|
|
ws_thread_loop = self._ws_thread_loop
|
|
if ws_thread_loop is not None and not ws_thread_loop.is_closed():
|
|
logger.debug("[Feishu] Cancelling websocket thread tasks and stopping loop")
|
|
|
|
def cancel_all_tasks() -> None:
|
|
tasks = [t for t in asyncio.all_tasks(ws_thread_loop) if not t.done()]
|
|
logger.debug("[Feishu] Found %d pending tasks in websocket thread", len(tasks))
|
|
for task in tasks:
|
|
task.cancel()
|
|
ws_thread_loop.call_later(0.1, ws_thread_loop.stop)
|
|
|
|
ws_thread_loop.call_soon_threadsafe(cancel_all_tasks)
|
|
|
|
ws_future = self._ws_future
|
|
if ws_future is not None:
|
|
try:
|
|
logger.debug("[Feishu] Waiting for websocket thread to exit (timeout=10s)")
|
|
await asyncio.wait_for(asyncio.shield(ws_future), timeout=10.0)
|
|
logger.debug("[Feishu] Websocket thread exited cleanly")
|
|
except asyncio.TimeoutError:
|
|
logger.warning("[Feishu] Websocket thread did not exit within 10s - may be stuck")
|
|
except asyncio.CancelledError:
|
|
logger.debug("[Feishu] Websocket thread cancelled during disconnect")
|
|
except Exception as exc:
|
|
logger.debug("[Feishu] Websocket thread exited with error: %s", exc, exc_info=True)
|
|
|
|
self._ws_future = None
|
|
self._ws_thread_loop = None
|
|
self._loop = None
|
|
self._event_handler = None
|
|
self._persist_seen_message_ids()
|
|
await self._release_app_lock()
|
|
|
|
self._mark_disconnected()
|
|
logger.info("[Feishu] Disconnected")
|
|
|
|
async def _cancel_pending_tasks(self, tasks: Dict[str, asyncio.Task]) -> None:
|
|
pending = [task for task in tasks.values() if task and not task.done()]
|
|
for task in pending:
|
|
task.cancel()
|
|
if pending:
|
|
await asyncio.gather(*pending, return_exceptions=True)
|
|
tasks.clear()
|
|
|
|
def _reset_batch_buffers(self) -> None:
|
|
self._pending_text_batches.clear()
|
|
self._pending_text_batch_counts.clear()
|
|
self._pending_media_batches.clear()
|
|
|
|
def _disable_websocket_auto_reconnect(self) -> None:
|
|
if self._ws_client is None:
|
|
return
|
|
try:
|
|
setattr(self._ws_client, "_auto_reconnect", False)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
self._ws_client = None
|
|
|
|
async def _stop_webhook_server(self) -> None:
|
|
if self._webhook_runner is None:
|
|
return
|
|
try:
|
|
await self._webhook_runner.cleanup()
|
|
finally:
|
|
self._webhook_runner = None
|
|
self._webhook_site = None
|
|
|
|
# =========================================================================
|
|
# Outbound — send / edit / send_image / send_voice / …
|
|
# =========================================================================
|
|
|
|
async def send(
|
|
self,
|
|
chat_id: str,
|
|
content: str,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
"""Send a Feishu message."""
|
|
if not self._client:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
formatted = self.format_message(content)
|
|
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
|
|
last_response = None
|
|
|
|
try:
|
|
for chunk in chunks:
|
|
msg_type, payload = self._build_outbound_payload(chunk)
|
|
try:
|
|
response = await self._feishu_send_with_retry(
|
|
chat_id=chat_id,
|
|
msg_type=msg_type,
|
|
payload=payload,
|
|
reply_to=reply_to,
|
|
metadata=metadata,
|
|
)
|
|
except Exception as exc:
|
|
if msg_type != "post" or not _POST_CONTENT_INVALID_RE.search(str(exc)):
|
|
raise
|
|
logger.warning("[Feishu] Invalid post payload rejected by API; falling back to plain text")
|
|
response = await self._feishu_send_with_retry(
|
|
chat_id=chat_id,
|
|
msg_type="text",
|
|
payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False),
|
|
reply_to=reply_to,
|
|
metadata=metadata,
|
|
)
|
|
if (
|
|
msg_type == "post"
|
|
and not self._response_succeeded(response)
|
|
and _POST_CONTENT_INVALID_RE.search(str(getattr(response, "msg", "") or ""))
|
|
):
|
|
logger.warning("[Feishu] Post payload rejected by API response; falling back to plain text")
|
|
response = await self._feishu_send_with_retry(
|
|
chat_id=chat_id,
|
|
msg_type="text",
|
|
payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False),
|
|
reply_to=reply_to,
|
|
metadata=metadata,
|
|
)
|
|
last_response = response
|
|
|
|
return self._finalize_send_result(last_response, "send failed")
|
|
except Exception as exc:
|
|
logger.error("[Feishu] Send error: %s", exc, exc_info=True)
|
|
return SendResult(success=False, error=str(exc))
|
|
|
|
async def edit_message(
|
|
self,
|
|
chat_id: str,
|
|
message_id: str,
|
|
content: str,
|
|
*,
|
|
finalize: bool = False,
|
|
) -> SendResult:
|
|
"""Edit a previously sent Feishu text/post message."""
|
|
if not self._client:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
content = self.format_message(content)
|
|
try:
|
|
msg_type, payload = self._build_outbound_payload(content)
|
|
body = self._build_update_message_body(msg_type=msg_type, content=payload)
|
|
request = self._build_update_message_request(message_id=message_id, request_body=body)
|
|
response = await 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_exec_approval(
|
|
self, chat_id: str, command: str, session_key: str,
|
|
description: str = "dangerous command",
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
"""Send an interactive card with approval buttons.
|
|
|
|
The buttons carry ``hermes_action`` in their value dict so that
|
|
``_handle_card_action_event`` can intercept them and call
|
|
``resolve_gateway_approval()`` to unblock the waiting agent thread.
|
|
"""
|
|
if not self._client:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
try:
|
|
approval_id = next(self._approval_counter)
|
|
cmd_preview = command[:3000] + "..." if len(command) > 3000 else command
|
|
|
|
def _btn(label: str, action_name: str, btn_type: str = "default") -> dict:
|
|
return {
|
|
"tag": "button",
|
|
"text": {"tag": "plain_text", "content": label},
|
|
"type": btn_type,
|
|
"value": {"hermes_action": action_name, "approval_id": approval_id},
|
|
}
|
|
|
|
card = {
|
|
"config": {"wide_screen_mode": True},
|
|
"header": {
|
|
"title": {"content": "⚠️ Command Approval Required", "tag": "plain_text"},
|
|
"template": "orange",
|
|
},
|
|
"elements": [
|
|
{
|
|
"tag": "markdown",
|
|
"content": f"```\n{cmd_preview}\n```\n**Reason:** {description}",
|
|
},
|
|
{
|
|
"tag": "action",
|
|
"actions": [
|
|
_btn("✅ Allow Once", "approve_once", "primary"),
|
|
_btn("✅ Session", "approve_session"),
|
|
_btn("✅ Always", "approve_always"),
|
|
_btn("❌ Deny", "deny", "danger"),
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
payload = json.dumps(card, ensure_ascii=False)
|
|
response = await self._feishu_send_with_retry(
|
|
chat_id=chat_id,
|
|
msg_type="interactive",
|
|
payload=payload,
|
|
reply_to=None,
|
|
metadata=metadata,
|
|
)
|
|
|
|
result = self._finalize_send_result(response, "send_exec_approval failed")
|
|
if result.success:
|
|
self._approval_state[approval_id] = {
|
|
"session_key": session_key,
|
|
"message_id": result.message_id or "",
|
|
"chat_id": chat_id,
|
|
}
|
|
return result
|
|
except Exception as exc:
|
|
logger.warning("[Feishu] send_exec_approval failed: %s", exc)
|
|
return SendResult(success=False, error=str(exc))
|
|
|
|
@staticmethod
|
|
def _build_resolved_approval_card(*, choice: str, user_name: str) -> Dict[str, Any]:
|
|
"""Build raw card JSON for a resolved approval action."""
|
|
icon = "❌" if choice == "deny" else "✅"
|
|
label = _APPROVAL_LABEL_MAP.get(choice, "Resolved")
|
|
return {
|
|
"config": {"wide_screen_mode": True},
|
|
"header": {
|
|
"title": {"content": f"{icon} {label}", "tag": "plain_text"},
|
|
"template": "red" if choice == "deny" else "green",
|
|
},
|
|
"elements": [
|
|
{
|
|
"tag": "markdown",
|
|
"content": f"{icon} **{label}** by {user_name}",
|
|
},
|
|
],
|
|
}
|
|
|
|
async def send_voice(
|
|
self,
|
|
chat_id: str,
|
|
audio_path: str,
|
|
caption: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
**kwargs,
|
|
) -> SendResult:
|
|
"""Send audio to Feishu as a file attachment plus optional caption."""
|
|
return await self._send_uploaded_file_message(
|
|
chat_id=chat_id,
|
|
file_path=audio_path,
|
|
reply_to=reply_to,
|
|
metadata=metadata,
|
|
caption=caption,
|
|
outbound_message_type="audio",
|
|
)
|
|
|
|
async def send_document(
|
|
self,
|
|
chat_id: str,
|
|
file_path: str,
|
|
caption: Optional[str] = None,
|
|
file_name: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
**kwargs,
|
|
) -> SendResult:
|
|
"""Send a document/file attachment to Feishu."""
|
|
return await self._send_uploaded_file_message(
|
|
chat_id=chat_id,
|
|
file_path=file_path,
|
|
reply_to=reply_to,
|
|
metadata=metadata,
|
|
caption=caption,
|
|
file_name=file_name,
|
|
)
|
|
|
|
async def send_video(
|
|
self,
|
|
chat_id: str,
|
|
video_path: str,
|
|
caption: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
**kwargs,
|
|
) -> SendResult:
|
|
"""Send a video file to Feishu."""
|
|
return await self._send_uploaded_file_message(
|
|
chat_id=chat_id,
|
|
file_path=video_path,
|
|
reply_to=reply_to,
|
|
metadata=metadata,
|
|
caption=caption,
|
|
outbound_message_type="media",
|
|
)
|
|
|
|
async def send_image_file(
|
|
self,
|
|
chat_id: str,
|
|
image_path: str,
|
|
caption: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
**kwargs,
|
|
) -> SendResult:
|
|
"""Send a local image file to Feishu."""
|
|
if not self._client:
|
|
return SendResult(success=False, error="Not connected")
|
|
if not os.path.exists(image_path):
|
|
return SendResult(success=False, error=f"Image file not found: {image_path}")
|
|
|
|
try:
|
|
import io as _io
|
|
with open(image_path, "rb") as f:
|
|
image_bytes = f.read()
|
|
# Wrap in BytesIO so lark SDK's MultipartEncoder can read .name and .tell()
|
|
image_file = _io.BytesIO(image_bytes)
|
|
image_file.name = os.path.basename(image_path)
|
|
body = self._build_image_upload_body(
|
|
image_type=_FEISHU_IMAGE_UPLOAD_TYPE,
|
|
image=image_file,
|
|
)
|
|
request = self._build_image_upload_request(body)
|
|
upload_response = await 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.
|
|
|
|
Called by the lark_oapi SDK's event dispatcher on a background thread.
|
|
If the adapter loop is not currently accepting callbacks (brief window
|
|
during startup/restart or network-flap reconnect), the event is queued
|
|
for replay instead of dropped.
|
|
"""
|
|
loop = self._loop
|
|
if not self._loop_accepts_callbacks(loop):
|
|
start_drainer = self._enqueue_pending_inbound_event(data)
|
|
if start_drainer:
|
|
threading.Thread(
|
|
target=self._drain_pending_inbound_events,
|
|
name="feishu-pending-inbound-drainer",
|
|
daemon=True,
|
|
).start()
|
|
return
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
self._handle_message_event_data(data),
|
|
loop,
|
|
)
|
|
future.add_done_callback(self._log_background_failure)
|
|
|
|
def _enqueue_pending_inbound_event(self, data: Any) -> bool:
|
|
"""Append an event to the pending-inbound queue.
|
|
|
|
Returns True if the caller should spawn a drainer thread (no drainer
|
|
currently scheduled), False if a drainer is already running and will
|
|
pick up the new event on its next pass.
|
|
"""
|
|
with self._pending_inbound_lock:
|
|
if len(self._pending_inbound_events) >= self._pending_inbound_max_depth:
|
|
# Queue full — drop the oldest to make room. This happens only
|
|
# if the loop stays unavailable for an extended period AND the
|
|
# WS keeps firing callbacks. Still better than silent drops.
|
|
dropped = self._pending_inbound_events.pop(0)
|
|
try:
|
|
event = getattr(dropped, "event", None)
|
|
message = getattr(event, "message", None)
|
|
message_id = str(getattr(message, "message_id", "") or "unknown")
|
|
except Exception:
|
|
message_id = "unknown"
|
|
logger.error(
|
|
"[Feishu] Pending-inbound queue full (%d); dropped oldest event %s",
|
|
self._pending_inbound_max_depth,
|
|
message_id,
|
|
)
|
|
self._pending_inbound_events.append(data)
|
|
depth = len(self._pending_inbound_events)
|
|
should_start = not self._pending_drain_scheduled
|
|
if should_start:
|
|
self._pending_drain_scheduled = True
|
|
logger.warning(
|
|
"[Feishu] Queued inbound event for replay (loop not ready, queue depth=%d)",
|
|
depth,
|
|
)
|
|
return should_start
|
|
|
|
def _drain_pending_inbound_events(self) -> None:
|
|
"""Replay queued inbound events once the adapter loop is ready.
|
|
|
|
Runs in a dedicated daemon thread. Polls ``_running`` and
|
|
``_loop_accepts_callbacks`` until events can be dispatched or the
|
|
adapter shuts down. A single drainer handles the entire queue;
|
|
concurrent ``_on_message_event`` calls just append.
|
|
"""
|
|
poll_interval = 0.25
|
|
max_wait_seconds = 120.0 # safety cap: drop queue after 2 minutes
|
|
waited = 0.0
|
|
try:
|
|
while True:
|
|
if not getattr(self, "_running", True):
|
|
# Adapter shutting down — drop queued events rather than
|
|
# holding them against a closed loop.
|
|
with self._pending_inbound_lock:
|
|
dropped = len(self._pending_inbound_events)
|
|
self._pending_inbound_events.clear()
|
|
if dropped:
|
|
logger.warning(
|
|
"[Feishu] Dropped %d queued inbound event(s) during shutdown",
|
|
dropped,
|
|
)
|
|
return
|
|
loop = self._loop
|
|
if self._loop_accepts_callbacks(loop):
|
|
with self._pending_inbound_lock:
|
|
batch = self._pending_inbound_events[:]
|
|
self._pending_inbound_events.clear()
|
|
if not batch:
|
|
# Queue emptied between check and grab; done.
|
|
with self._pending_inbound_lock:
|
|
if not self._pending_inbound_events:
|
|
return
|
|
continue
|
|
dispatched = 0
|
|
requeue: List[Any] = []
|
|
for event in batch:
|
|
try:
|
|
fut = asyncio.run_coroutine_threadsafe(
|
|
self._handle_message_event_data(event),
|
|
loop,
|
|
)
|
|
fut.add_done_callback(self._log_background_failure)
|
|
dispatched += 1
|
|
except RuntimeError:
|
|
# Loop closed between check and submit — requeue
|
|
# and poll again.
|
|
requeue.append(event)
|
|
if requeue:
|
|
with self._pending_inbound_lock:
|
|
self._pending_inbound_events[:0] = requeue
|
|
if dispatched:
|
|
logger.info(
|
|
"[Feishu] Replayed %d queued inbound event(s)",
|
|
dispatched,
|
|
)
|
|
if not requeue:
|
|
# Successfully drained; check if more arrived while
|
|
# we were dispatching and exit if not.
|
|
with self._pending_inbound_lock:
|
|
if not self._pending_inbound_events:
|
|
return
|
|
# More events queued or requeue pending — loop again.
|
|
continue
|
|
if waited >= max_wait_seconds:
|
|
with self._pending_inbound_lock:
|
|
dropped = len(self._pending_inbound_events)
|
|
self._pending_inbound_events.clear()
|
|
logger.error(
|
|
"[Feishu] Adapter loop unavailable for %.0fs; "
|
|
"dropped %d queued inbound event(s)",
|
|
max_wait_seconds,
|
|
dropped,
|
|
)
|
|
return
|
|
time.sleep(poll_interval)
|
|
waited += poll_interval
|
|
finally:
|
|
with self._pending_inbound_lock:
|
|
self._pending_drain_scheduled = False
|
|
|
|
async def _handle_message_event_data(self, data: Any) -> None:
|
|
"""Shared inbound message handling for websocket and webhook transports."""
|
|
event = getattr(data, "event", None)
|
|
message = getattr(event, "message", None)
|
|
sender = getattr(event, "sender", None)
|
|
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 self._is_self_sent_bot_message(event):
|
|
logger.debug("[Feishu] Dropping self-sent bot event: %s", message_id)
|
|
return
|
|
|
|
chat_type = getattr(message, "chat_type", "p2p")
|
|
chat_id = getattr(message, "chat_id", "") or ""
|
|
if chat_type != "p2p" and not self._should_accept_group_message(message, sender_id, chat_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_p2p_chat_entered(self, data: Any) -> None:
|
|
logger.debug("[Feishu] User entered P2P chat with bot")
|
|
|
|
def _on_message_recalled(self, data: Any) -> None:
|
|
logger.debug("[Feishu] Message recalled by user")
|
|
|
|
def _on_drive_comment_event(self, data: Any) -> None:
|
|
"""Handle drive document comment notification (drive.notice.comment_add_v1).
|
|
|
|
Delegates to :mod:`gateway.platforms.feishu_comment` for parsing,
|
|
logging, and reaction. Scheduling follows the same
|
|
``run_coroutine_threadsafe`` pattern used by ``_on_message_event``.
|
|
"""
|
|
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
|
|
|
loop = self._loop
|
|
if not self._loop_accepts_callbacks(loop):
|
|
logger.warning("[Feishu] Dropping drive comment event before adapter loop is ready")
|
|
return
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
handle_drive_comment_event(self._client, data, self_open_id=self._bot_open_id),
|
|
loop,
|
|
)
|
|
future.add_done_callback(self._log_background_failure)
|
|
|
|
def _on_reaction_event(self, event_type: str, data: Any) -> None:
|
|
"""Route user reactions on bot messages as synthetic text events."""
|
|
event = getattr(data, "event", None)
|
|
message_id = str(getattr(event, "message_id", "") or "")
|
|
operator_type = str(getattr(event, "operator_type", "") or "")
|
|
reaction_type_obj = getattr(event, "reaction_type", None)
|
|
emoji_type = str(getattr(reaction_type_obj, "emoji_type", "") or "")
|
|
action = "added" if "created" in event_type else "removed"
|
|
logger.debug(
|
|
"[Feishu] Reaction %s on message %s (operator_type=%s, emoji=%s)",
|
|
action,
|
|
message_id,
|
|
operator_type,
|
|
emoji_type,
|
|
)
|
|
# Drop bot/app-origin reactions to break the feedback loop from our
|
|
# own lifecycle reactions. A human reacting with the same emoji (e.g.
|
|
# clicking Typing on a bot message) is still routed through.
|
|
loop = self._loop
|
|
if (
|
|
operator_type in {"bot", "app"}
|
|
or not message_id
|
|
or loop is None
|
|
or bool(getattr(loop, "is_closed", lambda: False)())
|
|
):
|
|
return
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
self._handle_reaction_event(event_type, data),
|
|
loop,
|
|
)
|
|
future.add_done_callback(self._log_background_failure)
|
|
|
|
def _on_card_action_trigger(self, data: Any) -> Any:
|
|
"""Handle card-action callback from the Feishu SDK (synchronous).
|
|
|
|
For approval actions: parses the event once, returns the resolved card
|
|
inline (the only reliable way to sync all clients), and schedules a
|
|
lightweight async method to actually unblock the agent.
|
|
|
|
For other card actions: delegates to ``_handle_card_action_event``.
|
|
"""
|
|
loop = self._loop
|
|
if not self._loop_accepts_callbacks(loop):
|
|
logger.warning("[Feishu] Dropping card action before adapter loop is ready")
|
|
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
|
|
|
event = getattr(data, "event", None)
|
|
action = getattr(event, "action", None)
|
|
action_value = getattr(action, "value", {}) or {}
|
|
hermes_action = action_value.get("hermes_action") if isinstance(action_value, dict) else None
|
|
|
|
if hermes_action:
|
|
return self._handle_approval_card_action(event=event, action_value=action_value, loop=loop)
|
|
|
|
self._submit_on_loop(loop, self._handle_card_action_event(data))
|
|
if P2CardActionTriggerResponse is None:
|
|
return None
|
|
return P2CardActionTriggerResponse()
|
|
|
|
@staticmethod
|
|
def _loop_accepts_callbacks(loop: Any) -> bool:
|
|
"""Return True when the adapter loop can accept thread-safe submissions."""
|
|
return loop is not None and not bool(getattr(loop, "is_closed", lambda: False)())
|
|
|
|
def _submit_on_loop(self, loop: Any, coro: Any) -> None:
|
|
"""Schedule background work on the adapter loop with shared failure logging."""
|
|
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
|
future.add_done_callback(self._log_background_failure)
|
|
|
|
def _handle_approval_card_action(self, *, event: Any, action_value: Dict[str, Any], loop: Any) -> Any:
|
|
"""Schedule approval resolution and build the synchronous callback response."""
|
|
approval_id = action_value.get("approval_id")
|
|
if approval_id is None:
|
|
logger.debug("[Feishu] Card action missing approval_id, ignoring")
|
|
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
|
choice = _APPROVAL_CHOICE_MAP.get(action_value.get("hermes_action"), "deny")
|
|
|
|
operator = getattr(event, "operator", None)
|
|
open_id = str(getattr(operator, "open_id", "") or "")
|
|
user_name = self._get_cached_sender_name(open_id) or open_id
|
|
|
|
self._submit_on_loop(loop, self._resolve_approval(approval_id, choice, user_name))
|
|
|
|
if P2CardActionTriggerResponse is None:
|
|
return None
|
|
response = P2CardActionTriggerResponse()
|
|
if CallBackCard is not None:
|
|
card = CallBackCard()
|
|
card.type = "raw"
|
|
card.data = self._build_resolved_approval_card(choice=choice, user_name=user_name)
|
|
response.card = card
|
|
return response
|
|
|
|
async def _resolve_approval(self, approval_id: Any, choice: str, user_name: str) -> None:
|
|
"""Pop approval state and unblock the waiting agent thread."""
|
|
state = self._approval_state.pop(approval_id, None)
|
|
if not state:
|
|
logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id)
|
|
return
|
|
try:
|
|
from tools.approval import resolve_gateway_approval
|
|
count = resolve_gateway_approval(state["session_key"], choice)
|
|
logger.info(
|
|
"Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)",
|
|
count, state["session_key"], choice, user_name,
|
|
)
|
|
except Exception as exc:
|
|
logger.error("Failed to resolve gateway approval from Feishu button: %s", exc)
|
|
|
|
async def _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
|
|
before handing the event off to the agent.
|
|
|
|
Per-chat lock ensures messages in the same chat are processed one at a
|
|
time (matches openclaw's createChatQueue serial queue behaviour).
|
|
"""
|
|
chat_id = getattr(event.source, "chat_id", "") or "" if event.source else ""
|
|
chat_lock = self._get_chat_lock(chat_id)
|
|
async with chat_lock:
|
|
await self.handle_message(event)
|
|
|
|
# =========================================================================
|
|
# Processing status reactions
|
|
# =========================================================================
|
|
|
|
def _reactions_enabled(self) -> bool:
|
|
return os.getenv("FEISHU_REACTIONS", "true").strip().lower() not in ("false", "0", "no")
|
|
|
|
async def _add_reaction(self, message_id: str, emoji_type: str) -> Optional[str]:
|
|
"""Return the reaction_id on success, else None. The id is needed later for deletion."""
|
|
if not self._client or not message_id or not emoji_type:
|
|
return None
|
|
try:
|
|
from lark_oapi.api.im.v1 import (
|
|
CreateMessageReactionRequest,
|
|
CreateMessageReactionRequestBody,
|
|
)
|
|
body = (
|
|
CreateMessageReactionRequestBody.builder()
|
|
.reaction_type({"emoji_type": emoji_type})
|
|
.build()
|
|
)
|
|
request = (
|
|
CreateMessageReactionRequest.builder()
|
|
.message_id(message_id)
|
|
.request_body(body)
|
|
.build()
|
|
)
|
|
response = await 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.debug(
|
|
"[Feishu] Add reaction %s on %s rejected: code=%s msg=%s",
|
|
emoji_type,
|
|
message_id,
|
|
getattr(response, "code", None),
|
|
getattr(response, "msg", None),
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"[Feishu] Add reaction %s on %s raised",
|
|
emoji_type,
|
|
message_id,
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
async def _remove_reaction(self, message_id: str, reaction_id: str) -> bool:
|
|
if not self._client or not message_id or not reaction_id:
|
|
return False
|
|
try:
|
|
from lark_oapi.api.im.v1 import DeleteMessageReactionRequest
|
|
request = (
|
|
DeleteMessageReactionRequest.builder()
|
|
.message_id(message_id)
|
|
.reaction_id(reaction_id)
|
|
.build()
|
|
)
|
|
response = await asyncio.to_thread(self._client.im.v1.message_reaction.delete, request)
|
|
if response and getattr(response, "success", lambda: False)():
|
|
return True
|
|
logger.debug(
|
|
"[Feishu] Remove reaction %s on %s rejected: code=%s msg=%s",
|
|
reaction_id,
|
|
message_id,
|
|
getattr(response, "code", None),
|
|
getattr(response, "msg", None),
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"[Feishu] Remove reaction %s on %s raised",
|
|
reaction_id,
|
|
message_id,
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
|
|
def _remember_processing_reaction(self, message_id: str, reaction_id: str) -> None:
|
|
cache = self._pending_processing_reactions
|
|
cache[message_id] = reaction_id
|
|
cache.move_to_end(message_id)
|
|
while len(cache) > _FEISHU_PROCESSING_REACTION_CACHE_SIZE:
|
|
cache.popitem(last=False)
|
|
|
|
def _pop_processing_reaction(self, message_id: str) -> Optional[str]:
|
|
return self._pending_processing_reactions.pop(message_id, None)
|
|
|
|
async def on_processing_start(self, event: MessageEvent) -> None:
|
|
if not self._reactions_enabled():
|
|
return
|
|
message_id = event.message_id
|
|
if not message_id or message_id in self._pending_processing_reactions:
|
|
return
|
|
reaction_id = await self._add_reaction(message_id, _FEISHU_REACTION_IN_PROGRESS)
|
|
if reaction_id:
|
|
self._remember_processing_reaction(message_id, reaction_id)
|
|
|
|
async def on_processing_complete(
|
|
self, event: MessageEvent, outcome: ProcessingOutcome
|
|
) -> None:
|
|
if not self._reactions_enabled():
|
|
return
|
|
message_id = event.message_id
|
|
if not message_id:
|
|
return
|
|
|
|
start_reaction_id = self._pending_processing_reactions.get(message_id)
|
|
if start_reaction_id:
|
|
if not await self._remove_reaction(message_id, start_reaction_id):
|
|
# Don't stack a second badge on top of a Typing we couldn't
|
|
# remove — UI would read as both "working" and "done/failed"
|
|
# simultaneously. Keep the handle so LRU eventually evicts it.
|
|
return
|
|
self._pop_processing_reaction(message_id)
|
|
|
|
if outcome is ProcessingOutcome.FAILURE:
|
|
await self._add_reaction(message_id, _FEISHU_REACTION_FAILURE)
|
|
|
|
# =========================================================================
|
|
# Webhook server and security
|
|
# =========================================================================
|
|
|
|
def _record_webhook_anomaly(self, remote_ip: str, status: str) -> None:
|
|
"""Increment the anomaly counter for remote_ip and emit a WARNING every threshold hits.
|
|
|
|
Mirrors openclaw's createWebhookAnomalyTracker: TTL 6 hours, log every 25 consecutive
|
|
error responses from the same IP.
|
|
"""
|
|
now = time.time()
|
|
entry = self._webhook_anomaly_counts.get(remote_ip)
|
|
if entry is not None:
|
|
count, _last_status, first_seen = entry
|
|
if now - first_seen < _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS:
|
|
count += 1
|
|
if count % _FEISHU_WEBHOOK_ANOMALY_THRESHOLD == 0:
|
|
logger.warning(
|
|
"[Feishu] Webhook anomaly: %d consecutive error responses (%s) from %s "
|
|
"over the last %.0fs",
|
|
count,
|
|
status,
|
|
remote_ip,
|
|
now - first_seen,
|
|
)
|
|
self._webhook_anomaly_counts[remote_ip] = (count, status, first_seen)
|
|
return
|
|
# Either first occurrence or TTL expired — start fresh.
|
|
self._webhook_anomaly_counts[remote_ip] = (1, status, now)
|
|
|
|
def _clear_webhook_anomaly(self, remote_ip: str) -> None:
|
|
"""Reset the anomaly counter for remote_ip after a successful request."""
|
|
self._webhook_anomaly_counts.pop(remote_ip, None)
|
|
|
|
# =========================================================================
|
|
# Inbound processing pipeline
|
|
# =========================================================================
|
|
|
|
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, mentions = await self._extract_message_content(message)
|
|
|
|
if inbound_type == MessageType.TEXT:
|
|
text = _strip_edge_self_mentions(text, mentions)
|
|
if text.startswith("/"):
|
|
inbound_type = MessageType.COMMAND
|
|
|
|
# Guard runs post-strip so a pure "@Bot" message (stripped to "") is dropped.
|
|
if inbound_type == MessageType.TEXT and not text and not media_urls:
|
|
logger.debug("[Feishu] Ignoring empty text message id=%s", message_id)
|
|
return
|
|
|
|
if inbound_type != MessageType.COMMAND:
|
|
hint = _build_mention_hint(mentions)
|
|
if hint:
|
|
text = f"{hint}\n\n{text}" if text else hint
|
|
|
|
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),
|
|
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
|
)
|
|
return f"{session_key}:media:{event.message_type.value}"
|
|
|
|
@staticmethod
|
|
def _media_batch_is_compatible(existing: MessageEvent, incoming: MessageEvent) -> bool:
|
|
return (
|
|
existing.message_type == incoming.message_type
|
|
and existing.reply_to_message_id == incoming.reply_to_message_id
|
|
and existing.reply_to_text == incoming.reply_to_text
|
|
and existing.source.thread_id == incoming.source.thread_id
|
|
)
|
|
|
|
async def _enqueue_media_event(self, event: MessageEvent) -> None:
|
|
key = self._media_batch_key(event)
|
|
existing = self._pending_media_batches.get(key)
|
|
if existing is None:
|
|
self._pending_media_batches[key] = event
|
|
self._schedule_media_batch_flush(key)
|
|
return
|
|
if not self._media_batch_is_compatible(existing, event):
|
|
await self._flush_media_batch_now(key)
|
|
self._pending_media_batches[key] = event
|
|
self._schedule_media_batch_flush(key)
|
|
return
|
|
existing.media_urls.extend(event.media_urls)
|
|
existing.media_types.extend(event.media_types)
|
|
if event.text:
|
|
existing.text = self._merge_caption(existing.text, event.text)
|
|
existing.timestamp = event.timestamp
|
|
if event.message_id:
|
|
existing.message_id = event.message_id
|
|
self._schedule_media_batch_flush(key)
|
|
|
|
def _schedule_media_batch_flush(self, key: str) -> None:
|
|
self._reschedule_batch_task(
|
|
self._pending_media_batch_tasks,
|
|
key,
|
|
self._flush_media_batch,
|
|
)
|
|
|
|
async def _flush_media_batch(self, key: str) -> None:
|
|
current_task = asyncio.current_task()
|
|
try:
|
|
await asyncio.sleep(self._media_batch_delay_seconds)
|
|
await self._flush_media_batch_now(key)
|
|
finally:
|
|
if self._pending_media_batch_tasks.get(key) is current_task:
|
|
self._pending_media_batch_tasks.pop(key, None)
|
|
|
|
async def _flush_media_batch_now(self, key: str) -> None:
|
|
event = self._pending_media_batches.pop(key, None)
|
|
if not event:
|
|
return
|
|
logger.info(
|
|
"[Feishu] Flushing media batch %s with %d attachment(s)",
|
|
key,
|
|
len(event.media_urls),
|
|
)
|
|
await self._handle_message_with_guards(event)
|
|
|
|
async def _download_remote_image(self, image_url: str) -> str:
|
|
ext = self._guess_remote_extension(image_url, default=".jpg")
|
|
return await cache_image_from_url(image_url, ext=ext)
|
|
|
|
async def _download_remote_document(
|
|
self,
|
|
file_url: str,
|
|
*,
|
|
default_ext: str,
|
|
preferred_name: str,
|
|
) -> tuple[str, str]:
|
|
from tools.url_safety import is_safe_url
|
|
if not is_safe_url(file_url):
|
|
raise ValueError(f"Blocked unsafe URL (SSRF protection): {file_url[:80]}")
|
|
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
|
response = await client.get(
|
|
file_url,
|
|
headers={
|
|
"User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)",
|
|
"Accept": "*/*",
|
|
},
|
|
)
|
|
response.raise_for_status()
|
|
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":
|
|
self._on_message_event(data)
|
|
elif event_type == "im.message.message_read_v1":
|
|
self._on_message_read_event(data)
|
|
elif event_type == "im.chat.member.bot.added_v1":
|
|
self._on_bot_added_to_chat(data)
|
|
elif event_type == "im.chat.member.bot.deleted_v1":
|
|
self._on_bot_removed_from_chat(data)
|
|
elif event_type in ("im.message.reaction.created_v1", "im.message.reaction.deleted_v1"):
|
|
self._on_reaction_event(event_type, data)
|
|
elif event_type == "card.action.trigger":
|
|
self._on_card_action_trigger(data)
|
|
elif event_type == "drive.notice.comment_add_v1":
|
|
self._on_drive_comment_event(data)
|
|
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),
|
|
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
|
)
|
|
|
|
@staticmethod
|
|
def _text_batch_is_compatible(existing: MessageEvent, incoming: MessageEvent) -> bool:
|
|
"""Only merge text events when reply/thread context is identical."""
|
|
return (
|
|
existing.reply_to_message_id == incoming.reply_to_message_id
|
|
and existing.reply_to_text == incoming.reply_to_text
|
|
and existing.source.thread_id == incoming.source.thread_id
|
|
)
|
|
|
|
async def _enqueue_text_event(self, event: MessageEvent) -> None:
|
|
"""Debounce rapid Feishu text bursts into a single MessageEvent."""
|
|
key = self._text_batch_key(event)
|
|
chunk_len = len(event.text or "")
|
|
existing = self._pending_text_batches.get(key)
|
|
if existing is None:
|
|
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
|
self._pending_text_batches[key] = event
|
|
self._pending_text_batch_counts[key] = 1
|
|
self._schedule_text_batch_flush(key)
|
|
return
|
|
|
|
if not self._text_batch_is_compatible(existing, event):
|
|
await self._flush_text_batch_now(key)
|
|
self._pending_text_batches[key] = event
|
|
self._pending_text_batch_counts[key] = 1
|
|
self._schedule_text_batch_flush(key)
|
|
return
|
|
|
|
existing_count = self._pending_text_batch_counts.get(key, 1)
|
|
next_count = existing_count + 1
|
|
appended_text = event.text or ""
|
|
next_text = f"{existing.text}\n{appended_text}" if existing.text and appended_text else (existing.text or appended_text)
|
|
if next_count > self._text_batch_max_messages or len(next_text) > self._text_batch_max_chars:
|
|
await self._flush_text_batch_now(key)
|
|
self._pending_text_batches[key] = event
|
|
self._pending_text_batch_counts[key] = 1
|
|
self._schedule_text_batch_flush(key)
|
|
return
|
|
|
|
existing.text = next_text
|
|
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
|
existing.timestamp = event.timestamp
|
|
if event.message_id:
|
|
existing.message_id = event.message_id
|
|
self._pending_text_batch_counts[key] = next_count
|
|
self._schedule_text_batch_flush(key)
|
|
|
|
def _schedule_text_batch_flush(self, key: str) -> None:
|
|
"""Reset the debounce timer for a pending Feishu text batch."""
|
|
self._reschedule_batch_task(
|
|
self._pending_text_batch_tasks,
|
|
key,
|
|
self._flush_text_batch,
|
|
)
|
|
|
|
@staticmethod
|
|
def _reschedule_batch_task(
|
|
task_map: Dict[str, asyncio.Task],
|
|
key: str,
|
|
flush_fn: Any,
|
|
) -> None:
|
|
prior_task = task_map.get(key)
|
|
if prior_task and not prior_task.done():
|
|
prior_task.cancel()
|
|
task_map[key] = asyncio.create_task(flush_fn(key))
|
|
|
|
async def _flush_text_batch(self, key: str) -> None:
|
|
"""Flush a pending text batch after the quiet period.
|
|
|
|
Uses a longer delay when the latest chunk is near Feishu's ~4096-char
|
|
split point, since a continuation chunk is almost certain.
|
|
"""
|
|
current_task = asyncio.current_task()
|
|
try:
|
|
# Adaptive delay: if the latest chunk is near the split threshold,
|
|
# a continuation is almost certain — wait longer.
|
|
pending = self._pending_text_batches.get(key)
|
|
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
|
if last_len >= self._SPLIT_THRESHOLD:
|
|
delay = self._text_batch_split_delay_seconds
|
|
else:
|
|
delay = self._text_batch_delay_seconds
|
|
await asyncio.sleep(delay)
|
|
await self._flush_text_batch_now(key)
|
|
finally:
|
|
if self._pending_text_batch_tasks.get(key) is current_task:
|
|
self._pending_text_batch_tasks.pop(key, None)
|
|
|
|
async def _flush_text_batch_now(self, key: str) -> None:
|
|
"""Dispatch the current text batch immediately."""
|
|
event = self._pending_text_batches.pop(key, None)
|
|
self._pending_text_batch_counts.pop(key, None)
|
|
if not event:
|
|
return
|
|
logger.info(
|
|
"[Feishu] Flushing text batch %s (%d chars)",
|
|
key,
|
|
len(event.text or ""),
|
|
)
|
|
await self._handle_message_with_guards(event)
|
|
|
|
# =========================================================================
|
|
# Message content extraction and resource download
|
|
# =========================================================================
|
|
|
|
async def _extract_message_content(
|
|
self, message: Any
|
|
) -> tuple[str, MessageType, List[str], List[str], List[FeishuMentionRef]]:
|
|
raw_content = getattr(message, "content", "") or ""
|
|
raw_type = getattr(message, "message_type", "") or ""
|
|
message_id = str(getattr(message, "message_id", "") or "")
|
|
logger.info("[Feishu] Received raw message type=%s message_id=%s", raw_type, message_id)
|
|
|
|
normalized = normalize_feishu_message(
|
|
message_type=raw_type,
|
|
raw_content=raw_content,
|
|
mentions=getattr(message, "mentions", None),
|
|
bot=self._bot_identity(),
|
|
)
|
|
media_urls, media_types = await self._download_feishu_message_resources(
|
|
message_id=message_id,
|
|
normalized=normalized,
|
|
)
|
|
inbound_type = self._resolve_normalized_message_type(normalized, media_types)
|
|
text = normalized.text_content
|
|
|
|
if (
|
|
inbound_type in {MessageType.DOCUMENT, MessageType.AUDIO, MessageType.VIDEO, MessageType.PHOTO}
|
|
and len(media_urls) == 1
|
|
and normalized.preferred_message_type in {"document", "audio"}
|
|
):
|
|
injected = await self._maybe_extract_text_document(media_urls[0], media_types[0])
|
|
if injected:
|
|
text = injected
|
|
|
|
return text, inbound_type, media_urls, media_types, list(normalized.mentions)
|
|
|
|
async def _download_feishu_message_resources(
|
|
self,
|
|
*,
|
|
message_id: str,
|
|
normalized: FeishuNormalizedMessage,
|
|
) -> tuple[List[str], List[str]]:
|
|
media_urls: List[str] = []
|
|
media_types: List[str] = []
|
|
|
|
for image_key in normalized.image_keys:
|
|
cached_path, media_type = await self._download_feishu_image(
|
|
message_id=message_id,
|
|
image_key=image_key,
|
|
)
|
|
if cached_path:
|
|
media_urls.append(cached_path)
|
|
media_types.append(media_type)
|
|
|
|
for media_ref in normalized.media_refs:
|
|
cached_path, media_type = await self._download_feishu_message_resource(
|
|
message_id=message_id,
|
|
file_key=media_ref.file_key,
|
|
resource_type=media_ref.resource_type,
|
|
fallback_filename=media_ref.file_name,
|
|
)
|
|
if cached_path:
|
|
media_urls.append(cached_path)
|
|
media_types.append(media_type)
|
|
|
|
return media_urls, media_types
|
|
|
|
@staticmethod
|
|
def _resolve_media_message_type(media_type: str, *, default: MessageType) -> MessageType:
|
|
normalized = (media_type or "").lower()
|
|
if normalized.startswith("image/"):
|
|
return MessageType.PHOTO
|
|
if normalized.startswith("audio/"):
|
|
return MessageType.AUDIO
|
|
if normalized.startswith("video/"):
|
|
return MessageType.VIDEO
|
|
return default
|
|
|
|
def _resolve_normalized_message_type(
|
|
self,
|
|
normalized: FeishuNormalizedMessage,
|
|
media_types: List[str],
|
|
) -> MessageType:
|
|
preferred = normalized.preferred_message_type
|
|
if preferred == "photo":
|
|
return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.PHOTO)
|
|
if preferred == "audio":
|
|
return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.AUDIO)
|
|
if preferred == "document":
|
|
return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.DOCUMENT)
|
|
return MessageType.TEXT
|
|
|
|
async def _maybe_extract_text_document(self, cached_path: str, media_type: str) -> str:
|
|
if not cached_path or not media_type.startswith("text/"):
|
|
return ""
|
|
try:
|
|
if os.path.getsize(cached_path) > _MAX_TEXT_INJECT_BYTES:
|
|
return ""
|
|
ext = Path(cached_path).suffix.lower()
|
|
if ext not in {".txt", ".md"} and media_type not in {"text/plain", "text/markdown"}:
|
|
return ""
|
|
content = Path(cached_path).read_text(encoding="utf-8")
|
|
display_name = self._display_name_from_cached_path(cached_path)
|
|
return f"[Content of {display_name}]:\n{content}"
|
|
except (OSError, UnicodeDecodeError):
|
|
logger.warning("[Feishu] Failed to inject text document content from %s", cached_path, exc_info=True)
|
|
return ""
|
|
|
|
async def _download_feishu_image(self, *, message_id: str, image_key: str) -> tuple[str, str]:
|
|
if not self._client or not message_id:
|
|
return "", ""
|
|
try:
|
|
request = self._build_message_resource_request(
|
|
message_id=message_id,
|
|
file_key=image_key,
|
|
resource_type="image",
|
|
)
|
|
response = await 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]]:
|
|
"""Map Feishu's three-tier user IDs onto Hermes' SessionSource fields.
|
|
|
|
Preference order for the primary ``user_id`` field:
|
|
1. user_id (tenant-scoped, most stable — requires permission scope)
|
|
2. open_id (app-scoped, always available — different per bot app)
|
|
|
|
``user_id_alt`` carries the union_id (developer-scoped, stable across
|
|
all apps by the same developer). Session-key generation prefers
|
|
user_id_alt when present, so participant isolation stays stable even
|
|
if the primary ID is the app-scoped open_id.
|
|
"""
|
|
open_id = getattr(sender_id, "open_id", None) or None
|
|
user_id = getattr(sender_id, "user_id", None) or None
|
|
union_id = getattr(sender_id, "union_id", None) or None
|
|
# Prefer tenant-scoped user_id; fall back to app-scoped open_id.
|
|
primary_id = user_id or open_id
|
|
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,
|
|
}
|
|
|
|
def _get_cached_sender_name(self, sender_id: Optional[str]) -> Optional[str]:
|
|
"""Return a cached sender name only while its TTL is still valid."""
|
|
if not sender_id:
|
|
return None
|
|
cached = self._sender_name_cache.get(sender_id)
|
|
if cached is None:
|
|
return None
|
|
name, expire_at = cached
|
|
if time.time() < expire_at:
|
|
return name
|
|
self._sender_name_cache.pop(sender_id, None)
|
|
return None
|
|
|
|
async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> 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_name = self._get_cached_sender_name(trimmed)
|
|
if cached_name is not None:
|
|
return cached_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 ""
|
|
parent_mentions = getattr(parent, "mentions", None) if parent else None
|
|
text = self._extract_text_from_raw_content(
|
|
msg_type=msg_type,
|
|
raw_content=raw_content,
|
|
mentions=parent_mentions,
|
|
)
|
|
self._message_text_cache[message_id] = text
|
|
return text
|
|
except Exception:
|
|
logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True)
|
|
return None
|
|
|
|
def _extract_text_from_raw_content(
|
|
self,
|
|
*,
|
|
msg_type: str,
|
|
raw_content: str,
|
|
mentions: Optional[Sequence[Any]] = None,
|
|
) -> Optional[str]:
|
|
normalized = normalize_feishu_message(
|
|
message_type=msg_type,
|
|
raw_content=raw_content,
|
|
mentions=mentions,
|
|
bot=self._bot_identity(),
|
|
)
|
|
if normalized.text_content:
|
|
return normalized.text_content
|
|
placeholder = normalized.metadata.get("placeholder_text") if isinstance(normalized.metadata, dict) else None
|
|
return str(placeholder).strip() or None
|
|
|
|
@staticmethod
|
|
def _default_image_media_type(ext: str) -> str:
|
|
normalized_ext = (ext or "").lower()
|
|
if normalized_ext in {".jpg", ".jpeg"}:
|
|
return "image/jpeg"
|
|
return f"image/{normalized_ext.lstrip('.') or 'jpeg'}"
|
|
|
|
@staticmethod
|
|
def _log_background_failure(future: Any) -> None:
|
|
try:
|
|
future.result()
|
|
except Exception:
|
|
logger.exception("[Feishu] Background inbound processing failed")
|
|
|
|
# =========================================================================
|
|
# Group policy and mention gating
|
|
# =========================================================================
|
|
|
|
def _allow_group_message(self, sender_id: Any, chat_id: str = "") -> bool:
|
|
"""Per-group policy gate for non-DM traffic."""
|
|
sender_open_id = getattr(sender_id, "open_id", None)
|
|
sender_user_id = getattr(sender_id, "user_id", None)
|
|
sender_ids = {sender_open_id, sender_user_id} - {None}
|
|
|
|
if sender_ids and self._admins and (sender_ids & self._admins):
|
|
return True
|
|
|
|
rule = self._group_rules.get(chat_id) if chat_id else None
|
|
if rule:
|
|
policy = rule.policy
|
|
allowlist = rule.allowlist
|
|
blacklist = rule.blacklist
|
|
else:
|
|
policy = self._default_group_policy or self._group_policy
|
|
allowlist = self._allowed_group_users
|
|
blacklist = set()
|
|
|
|
if policy == "disabled":
|
|
return False
|
|
if policy == "open":
|
|
return True
|
|
if policy == "admin_only":
|
|
return False
|
|
if policy == "allowlist":
|
|
return bool(sender_ids and (sender_ids & allowlist))
|
|
if policy == "blacklist":
|
|
return bool(sender_ids and not (sender_ids & blacklist))
|
|
|
|
return bool(sender_ids and (sender_ids & self._allowed_group_users))
|
|
|
|
def _should_accept_group_message(self, message: Any, sender_id: Any, chat_id: str = "") -> bool:
|
|
"""Require an explicit @mention before group messages enter the agent."""
|
|
if not self._allow_group_message(sender_id, chat_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,
|
|
mentions=getattr(message, "mentions", None),
|
|
bot=self._bot_identity(),
|
|
)
|
|
return self._post_mentions_bot(normalized.mentions)
|
|
|
|
def _is_self_sent_bot_message(self, event: Any) -> bool:
|
|
"""Return True only for Feishu events emitted by this Hermes bot."""
|
|
sender = getattr(event, "sender", None)
|
|
sender_type = str(getattr(sender, "sender_type", "") or "").strip().lower()
|
|
if sender_type not in {"bot", "app"}:
|
|
return False
|
|
|
|
sender_id = getattr(sender, "sender_id", None)
|
|
sender_open_id = str(getattr(sender_id, "open_id", "") or "").strip()
|
|
sender_user_id = str(getattr(sender_id, "user_id", "") or "").strip()
|
|
|
|
if self._bot_open_id and sender_open_id == self._bot_open_id:
|
|
return True
|
|
if self._bot_user_id and sender_user_id == self._bot_user_id:
|
|
return True
|
|
return False
|
|
|
|
def _message_mentions_bot(self, mentions: List[Any]) -> bool:
|
|
# IDs trump names: when both sides have open_id (or both user_id),
|
|
# match requires equal IDs. Name fallback only when either side
|
|
# lacks an ID.
|
|
for mention in mentions:
|
|
mention_id = getattr(mention, "id", None)
|
|
mention_open_id = (getattr(mention_id, "open_id", None) or "").strip()
|
|
mention_user_id = (getattr(mention_id, "user_id", None) or "").strip()
|
|
mention_name = (getattr(mention, "name", None) or "").strip()
|
|
|
|
if mention_open_id and self._bot_open_id:
|
|
if mention_open_id == self._bot_open_id:
|
|
return True
|
|
continue # IDs differ — not the bot; skip name fallback.
|
|
if mention_user_id and self._bot_user_id:
|
|
if mention_user_id == self._bot_user_id:
|
|
return True
|
|
continue
|
|
if self._bot_name and mention_name == self._bot_name:
|
|
return True
|
|
|
|
return False
|
|
|
|
def _post_mentions_bot(self, mentions: List[FeishuMentionRef]) -> bool:
|
|
return any(m.is_self for m in mentions)
|
|
|
|
def _bot_identity(self) -> _FeishuBotIdentity:
|
|
return _FeishuBotIdentity(
|
|
open_id=self._bot_open_id,
|
|
user_id=self._bot_user_id,
|
|
name=self._bot_name,
|
|
)
|
|
|
|
async def _hydrate_bot_identity(self) -> None:
|
|
"""Best-effort discovery of bot identity for precise group mention gating
|
|
and self-sent bot event filtering.
|
|
|
|
Populates ``_bot_open_id`` and ``_bot_name`` from /open-apis/bot/v3/info
|
|
(no extra scopes required beyond the tenant access token). Falls back to
|
|
the application info endpoint for ``_bot_name`` only when the first probe
|
|
doesn't return it. Each field is hydrated independently — a value already
|
|
supplied via env vars (FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID /
|
|
FEISHU_BOT_NAME) is preserved and skips its probe.
|
|
"""
|
|
if not self._client:
|
|
return
|
|
if self._bot_open_id and self._bot_name:
|
|
# Everything the self-send filter and precise mention gate need is
|
|
# already in place; nothing to probe.
|
|
return
|
|
|
|
# Primary probe: /open-apis/bot/v3/info — returns bot_name + open_id, no
|
|
# extra scopes required. This is the same endpoint the onboarding wizard
|
|
# uses via probe_bot().
|
|
if not self._bot_open_id or not self._bot_name:
|
|
try:
|
|
req = (
|
|
BaseRequest.builder()
|
|
.http_method(HttpMethod.GET)
|
|
.uri("/open-apis/bot/v3/info")
|
|
.token_types({AccessTokenType.TENANT})
|
|
.build()
|
|
)
|
|
resp = await asyncio.to_thread(self._client.request, req)
|
|
content = getattr(getattr(resp, "raw", None), "content", None)
|
|
if content:
|
|
payload = json.loads(content)
|
|
parsed = _parse_bot_response(payload) or {}
|
|
open_id = (parsed.get("bot_open_id") or "").strip()
|
|
bot_name = (parsed.get("bot_name") or "").strip()
|
|
if open_id and not self._bot_open_id:
|
|
self._bot_open_id = open_id
|
|
if bot_name and not self._bot_name:
|
|
self._bot_name = bot_name
|
|
except Exception:
|
|
logger.debug(
|
|
"[Feishu] /bot/v3/info probe failed during hydration",
|
|
exc_info=True,
|
|
)
|
|
|
|
# Fallback probe for _bot_name only: application info endpoint. Needs
|
|
# admin:app.info:readonly or application:application:self_manage scope,
|
|
# so it's best-effort.
|
|
if self._bot_name:
|
|
return
|
|
try:
|
|
request = self._build_get_application_request(app_id=self._app_id, lang="en_us")
|
|
response = await 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 name from application info. "
|
|
"Grant admin:app.info:readonly or application:application:self_manage "
|
|
"so group @mention gating can resolve the bot name precisely."
|
|
)
|
|
return
|
|
app = getattr(getattr(response, "data", None), "app", None)
|
|
app_name = (getattr(app, "app_name", None) or "").strip()
|
|
if app_name and not self._bot_name:
|
|
self._bot_name = app_name
|
|
except Exception:
|
|
logger.debug("[Feishu] Failed to hydrate bot name from application info", exc_info=True)
|
|
|
|
# =========================================================================
|
|
# Deduplication — seen message ID cache (persistent)
|
|
# =========================================================================
|
|
|
|
def _load_seen_message_ids(self) -> None:
|
|
try:
|
|
payload = json.loads(self._dedup_state_path.read_text(encoding="utf-8"))
|
|
except FileNotFoundError:
|
|
return
|
|
except (OSError, json.JSONDecodeError):
|
|
logger.warning("[Feishu] Failed to load persisted dedup state from %s", self._dedup_state_path, exc_info=True)
|
|
return
|
|
seen_data = payload.get("message_ids", {}) if isinstance(payload, dict) else {}
|
|
now = time.time()
|
|
ttl = _FEISHU_DEDUP_TTL_SECONDS
|
|
# Backward-compat: old format stored a plain list of IDs (no timestamps).
|
|
if isinstance(seen_data, list):
|
|
entries: Dict[str, float] = {str(item).strip(): 0.0 for item in seen_data if str(item).strip()}
|
|
elif isinstance(seen_data, dict):
|
|
entries = {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:
|
|
thread_id = (metadata or {}).get("thread_id")
|
|
reply_in_thread = bool(thread_id)
|
|
effective_reply_to = reply_to or (thread_id if reply_in_thread else None)
|
|
if effective_reply_to:
|
|
body = self._build_reply_message_body(
|
|
content=payload,
|
|
msg_type=msg_type,
|
|
reply_in_thread=reply_in_thread,
|
|
uuid_value=str(uuid.uuid4()),
|
|
)
|
|
request = self._build_reply_message_request(effective_reply_to, body)
|
|
return await 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)
|
|
self._event_handler = self._build_event_handler()
|
|
if self._event_handler is None:
|
|
raise RuntimeError("failed to build Feishu event handler")
|
|
loop = self._loop
|
|
if loop is None or loop.is_closed():
|
|
raise RuntimeError("adapter loop is not ready")
|
|
await self._hydrate_bot_identity()
|
|
self._ws_client = FeishuWSClient(
|
|
app_id=self._app_id,
|
|
app_secret=self._app_secret,
|
|
log_level=lark.LogLevel.INFO,
|
|
event_handler=self._event_handler,
|
|
domain=domain,
|
|
)
|
|
self._ws_future = loop.run_in_executor(
|
|
None,
|
|
_run_official_feishu_ws_client,
|
|
self._ws_client,
|
|
self,
|
|
)
|
|
|
|
async def _connect_webhook(self) -> None:
|
|
if not FEISHU_WEBHOOK_AVAILABLE:
|
|
raise RuntimeError("aiohttp not installed; webhook mode unavailable")
|
|
domain = FEISHU_DOMAIN if self._domain_name != "lark" else LARK_DOMAIN
|
|
self._client = self._build_lark_client(domain)
|
|
self._event_handler = self._build_event_handler()
|
|
if self._event_handler is None:
|
|
raise RuntimeError("failed to build Feishu event handler")
|
|
await self._hydrate_bot_identity()
|
|
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"
|
|
|
|
|
|
# =============================================================================
|
|
# QR scan-to-create onboarding
|
|
#
|
|
# Device-code flow: user scans a QR code with Feishu/Lark mobile app and the
|
|
# platform creates a fully configured bot application automatically.
|
|
# Called by `hermes gateway setup` via _setup_feishu() in hermes_cli/gateway.py.
|
|
# =============================================================================
|
|
|
|
|
|
def _accounts_base_url(domain: str) -> str:
|
|
return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"])
|
|
|
|
|
|
def _onboard_open_base_url(domain: str) -> str:
|
|
return _ONBOARD_OPEN_URLS.get(domain, _ONBOARD_OPEN_URLS["feishu"])
|
|
|
|
|
|
def _post_registration(base_url: str, body: Dict[str, str]) -> dict:
|
|
"""POST form-encoded data to the registration endpoint, return parsed JSON.
|
|
|
|
The registration endpoint returns JSON even on 4xx (e.g. poll returns
|
|
authorization_pending as a 400). We always parse the body regardless of
|
|
HTTP status.
|
|
"""
|
|
url = f"{base_url}{_REGISTRATION_PATH}"
|
|
data = urlencode(body).encode("utf-8")
|
|
req = Request(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
|
|
try:
|
|
with urlopen(req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
except HTTPError as exc:
|
|
body_bytes = exc.read()
|
|
if body_bytes:
|
|
try:
|
|
return json.loads(body_bytes.decode("utf-8"))
|
|
except (ValueError, json.JSONDecodeError):
|
|
raise exc from None
|
|
raise
|
|
|
|
|
|
def _init_registration(domain: str = "feishu") -> None:
|
|
"""Verify the environment supports client_secret auth.
|
|
|
|
Raises RuntimeError if not supported.
|
|
"""
|
|
base_url = _accounts_base_url(domain)
|
|
res = _post_registration(base_url, {"action": "init"})
|
|
methods = res.get("supported_auth_methods") or []
|
|
if "client_secret" not in methods:
|
|
raise RuntimeError(
|
|
f"Feishu / Lark registration environment does not support client_secret auth. "
|
|
f"Supported: {methods}"
|
|
)
|
|
|
|
|
|
def _begin_registration(domain: str = "feishu") -> dict:
|
|
"""Start the device-code flow. Returns device_code, qr_url, user_code, interval, expire_in."""
|
|
base_url = _accounts_base_url(domain)
|
|
res = _post_registration(base_url, {
|
|
"action": "begin",
|
|
"archetype": "PersonalAgent",
|
|
"auth_method": "client_secret",
|
|
"request_user_info": "open_id",
|
|
})
|
|
device_code = res.get("device_code")
|
|
if not device_code:
|
|
raise RuntimeError("Feishu / Lark registration did not return a device_code")
|
|
qr_url = res.get("verification_uri_complete", "")
|
|
if "?" in qr_url:
|
|
qr_url += "&from=hermes&tp=hermes"
|
|
else:
|
|
qr_url += "?from=hermes&tp=hermes"
|
|
return {
|
|
"device_code": device_code,
|
|
"qr_url": qr_url,
|
|
"user_code": res.get("user_code", ""),
|
|
"interval": res.get("interval") or 5,
|
|
"expire_in": res.get("expire_in") or 600,
|
|
}
|
|
|
|
|
|
def _poll_registration(
|
|
*,
|
|
device_code: str,
|
|
interval: int,
|
|
expire_in: int,
|
|
domain: str = "feishu",
|
|
) -> Optional[dict]:
|
|
"""Poll until the user scans the QR code, or timeout/denial.
|
|
|
|
Returns dict with app_id, app_secret, domain, open_id on success.
|
|
Returns None on failure.
|
|
"""
|
|
deadline = time.time() + expire_in
|
|
current_domain = domain
|
|
domain_switched = False
|
|
poll_count = 0
|
|
|
|
while time.time() < deadline:
|
|
base_url = _accounts_base_url(current_domain)
|
|
try:
|
|
res = _post_registration(base_url, {
|
|
"action": "poll",
|
|
"device_code": device_code,
|
|
"tp": "ob_app",
|
|
})
|
|
except (URLError, OSError, json.JSONDecodeError):
|
|
time.sleep(interval)
|
|
continue
|
|
|
|
poll_count += 1
|
|
if poll_count == 1:
|
|
print(" Fetching configuration results...", end="", flush=True)
|
|
elif poll_count % 6 == 0:
|
|
print(".", end="", flush=True)
|
|
|
|
# Domain auto-detection
|
|
user_info = res.get("user_info") or {}
|
|
tenant_brand = user_info.get("tenant_brand")
|
|
if tenant_brand == "lark" and not domain_switched:
|
|
current_domain = "lark"
|
|
domain_switched = True
|
|
# Fall through — server may return credentials in this same response.
|
|
|
|
# Success
|
|
if res.get("client_id") and res.get("client_secret"):
|
|
if poll_count > 0:
|
|
print() # newline after "Fetching configuration results..." dots
|
|
return {
|
|
"app_id": res["client_id"],
|
|
"app_secret": res["client_secret"],
|
|
"domain": current_domain,
|
|
"open_id": user_info.get("open_id"),
|
|
}
|
|
|
|
# Terminal errors
|
|
error = res.get("error", "")
|
|
if error in ("access_denied", "expired_token"):
|
|
if poll_count > 0:
|
|
print()
|
|
logger.warning("[Feishu onboard] Registration %s", error)
|
|
return None
|
|
|
|
# authorization_pending or unknown — keep polling
|
|
time.sleep(interval)
|
|
|
|
if poll_count > 0:
|
|
print()
|
|
logger.warning("[Feishu onboard] Poll timed out after %ds", expire_in)
|
|
return None
|
|
|
|
|
|
try:
|
|
import qrcode as _qrcode_mod
|
|
except (ImportError, TypeError):
|
|
_qrcode_mod = None # type: ignore[assignment]
|
|
|
|
|
|
def _render_qr(url: str) -> bool:
|
|
"""Try to render a QR code in the terminal. Returns True if successful."""
|
|
if _qrcode_mod is None:
|
|
return False
|
|
try:
|
|
qr = _qrcode_mod.QRCode()
|
|
qr.add_data(url)
|
|
qr.make(fit=True)
|
|
qr.print_ascii(invert=True)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
|
|
"""Verify bot connectivity via /open-apis/bot/v3/info.
|
|
|
|
Uses lark_oapi SDK when available, falls back to raw HTTP otherwise.
|
|
Returns {"bot_name": ..., "bot_open_id": ...} on success, None on failure.
|
|
|
|
Note: ``bot_open_id`` here is the bot's app-scoped open_id — the same ID
|
|
that Feishu puts in @mention payloads. It is NOT the app_id.
|
|
"""
|
|
if FEISHU_AVAILABLE:
|
|
return _probe_bot_sdk(app_id, app_secret, domain)
|
|
return _probe_bot_http(app_id, app_secret, domain)
|
|
|
|
|
|
def _build_onboard_client(app_id: str, app_secret: str, domain: str) -> Any:
|
|
"""Build a lark Client for the given credentials and domain."""
|
|
sdk_domain = LARK_DOMAIN if domain == "lark" else FEISHU_DOMAIN
|
|
return (
|
|
lark.Client.builder()
|
|
.app_id(app_id)
|
|
.app_secret(app_secret)
|
|
.domain(sdk_domain)
|
|
.log_level(lark.LogLevel.WARNING)
|
|
.build()
|
|
)
|
|
|
|
|
|
def _parse_bot_response(data: dict) -> Optional[dict]:
|
|
# /bot/v3/info returns bot.app_name; legacy paths used bot_name — accept both.
|
|
if data.get("code") != 0:
|
|
return None
|
|
bot = data.get("bot") or data.get("data", {}).get("bot") or {}
|
|
return {
|
|
"bot_name": bot.get("app_name") or bot.get("bot_name"),
|
|
"bot_open_id": bot.get("open_id"),
|
|
}
|
|
|
|
|
|
def _probe_bot_sdk(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
|
|
"""Probe bot info using lark_oapi SDK."""
|
|
try:
|
|
client = _build_onboard_client(app_id, app_secret, domain)
|
|
req = (
|
|
BaseRequest.builder()
|
|
.http_method(HttpMethod.GET)
|
|
.uri("/open-apis/bot/v3/info")
|
|
.token_types({AccessTokenType.TENANT})
|
|
.build()
|
|
)
|
|
resp = client.request(req)
|
|
content = getattr(getattr(resp, "raw", None), "content", None)
|
|
if content is None:
|
|
return None
|
|
return _parse_bot_response(json.loads(content))
|
|
except Exception as exc:
|
|
logger.debug("[Feishu onboard] SDK probe failed: %s", exc)
|
|
return None
|
|
|
|
|
|
def _probe_bot_http(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
|
|
"""Fallback probe using raw HTTP (when lark_oapi is not installed)."""
|
|
base_url = _onboard_open_base_url(domain)
|
|
try:
|
|
token_data = json.dumps({"app_id": app_id, "app_secret": app_secret}).encode("utf-8")
|
|
token_req = Request(
|
|
f"{base_url}/open-apis/auth/v3/tenant_access_token/internal",
|
|
data=token_data,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with urlopen(token_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
|
|
token_res = json.loads(resp.read().decode("utf-8"))
|
|
|
|
access_token = token_res.get("tenant_access_token")
|
|
if not access_token:
|
|
return None
|
|
|
|
bot_req = Request(
|
|
f"{base_url}/open-apis/bot/v3/info",
|
|
headers={
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|
|
with urlopen(bot_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
|
|
bot_res = json.loads(resp.read().decode("utf-8"))
|
|
|
|
return _parse_bot_response(bot_res)
|
|
except (URLError, OSError, KeyError, json.JSONDecodeError) as exc:
|
|
logger.debug("[Feishu onboard] HTTP probe failed: %s", exc)
|
|
return None
|
|
|
|
|
|
def qr_register(
|
|
*,
|
|
initial_domain: str = "feishu",
|
|
timeout_seconds: int = 600,
|
|
) -> Optional[dict]:
|
|
"""Run the Feishu / Lark scan-to-create QR registration flow.
|
|
|
|
Returns on success::
|
|
|
|
{
|
|
"app_id": str,
|
|
"app_secret": str,
|
|
"domain": "feishu" | "lark",
|
|
"open_id": str | None,
|
|
"bot_name": str | None,
|
|
"bot_open_id": str | None,
|
|
}
|
|
|
|
Returns None on expected failures (network, auth denied, timeout).
|
|
Unexpected errors (bugs, protocol regressions) propagate to the caller.
|
|
"""
|
|
try:
|
|
return _qr_register_inner(initial_domain=initial_domain, timeout_seconds=timeout_seconds)
|
|
except (RuntimeError, URLError, OSError, json.JSONDecodeError) as exc:
|
|
logger.warning("[Feishu onboard] Registration failed: %s", exc)
|
|
return None
|
|
|
|
|
|
def _qr_register_inner(
|
|
*,
|
|
initial_domain: str,
|
|
timeout_seconds: int,
|
|
) -> Optional[dict]:
|
|
"""Run init → begin → poll → probe. Raises on network/protocol errors."""
|
|
print(" Connecting to Feishu / Lark...", end="", flush=True)
|
|
_init_registration(initial_domain)
|
|
begin = _begin_registration(initial_domain)
|
|
print(" done.")
|
|
|
|
print()
|
|
qr_url = begin["qr_url"]
|
|
if _render_qr(qr_url):
|
|
print(f"\n Scan the QR code above, or open this URL directly:\n {qr_url}")
|
|
else:
|
|
print(f" Open this URL in Feishu / Lark on your phone:\n\n {qr_url}\n")
|
|
print(" Tip: pip install qrcode to display a scannable QR code here next time")
|
|
print()
|
|
|
|
result = _poll_registration(
|
|
device_code=begin["device_code"],
|
|
interval=begin["interval"],
|
|
expire_in=min(begin["expire_in"], timeout_seconds),
|
|
domain=initial_domain,
|
|
)
|
|
if not result:
|
|
return None
|
|
|
|
# Probe bot — best-effort, don't fail the registration
|
|
bot_info = probe_bot(result["app_id"], result["app_secret"], result["domain"])
|
|
if bot_info:
|
|
result["bot_name"] = bot_info.get("bot_name")
|
|
result["bot_open_id"] = bot_info.get("bot_open_id")
|
|
else:
|
|
result["bot_name"] = None
|
|
result["bot_open_id"] = None
|
|
|
|
return result
|