ForcePilot/backend/package/yuxi/channels/adapters/feishu/adapter.py
Kris a6fa7245e5 feat(feishu): 完整实现飞书适配器核心模块
新增飞书机器人适配器全套功能,包括:
- 基础适配器入口与工具导出
- 消息格式化、卡片渲染、回复调度逻辑
- 会话ID生成、模型覆盖策略
- 消息发送缓存、顺序队列管理
- 飞书签名验证、加解密webhook请求
- 审批权限校验、机器人菜单事件处理
- 文档评论、钉消息、语音转码处理
- 静态/动态目录管理、子代理生命周期管理
- 各类工具集:聊天、云盘、文档、知识库API封装
2026-05-12 00:43:59 +08:00

1281 lines
51 KiB
Python

from __future__ import annotations
import asyncio
import json
import os
import time
from typing import Any, ClassVar
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelConnectionError,
)
from yuxi.channels.models import (
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
ChatType,
DeliveryResult,
HealthStatus,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
from .cards import TEXT_CHUNK_LIMIT
from .accounts import FeishuAccountManager
from .approval import FeishuApprovalAdapter
from .chat_cache import ChatNameCache
from .client import FeishuClientCache
from .commands import build_synthetic_message, parse_bot_menu_event
from .comment_handler import handle_comment_event
from .dedup import FeishuDedupStore
from .directory import list_groups as _list_groups_feishu, list_peers as _list_peers_feishu
from .dynamic_agent import FeishuDynamicAgentConfig, FeishuDynamicAgentManager
from .card_action import decode_card_action, validate_card_context
from .formatter import format_outbound
from .media import (
download_media as _download_media,
upload_file,
upload_image,
)
from .normalizer import normalize_inbound
from .pins import create_pin as _create_pin, list_pins as _list_pins, remove_pin as _remove_pin
from .reactions import (
clear_all_bot_reactions,
list_reactions as _list_reactions,
remove_reaction as _remove_reaction,
)
from .reply_dispatcher import dispatch_render, extract_urls
from .secret_resolver import resolve_secret
from .send import (
is_local_image_path,
read_message as _read_message,
reply_message as _reply_message,
send_card,
send_reaction as _send_reaction,
send_text,
update_card_message,
)
from .sequential import FeishuSequentialQueue
from .session import CHAT_ID_PREFIX_DM, CHAT_ID_PREFIX_GROUP
from .stream import STREAM_START_BACKOFF_S, FeishuStreamingSession
from .thread_bindings import ThreadBindingManager
from .tts import FeishuTTSConfig, send_tts_audio
from .typing import TypingIndicator
from .verify import verify_and_decrypt_webhook, verify_feishu_signature
try:
import lark_oapi
HAS_LARK_SDK = True
except ImportError:
HAS_LARK_SDK = False
lark_oapi = None # type: ignore
@register_builtin_adapter
class FeishuAdapter(BaseChannelAdapter):
channel_id = "feishu"
channel_type = ChannelType.FEISHU
text_chunk_limit = TEXT_CHUNK_LIMIT
supports_markdown = True
supports_streaming = True
streaming_modes = ["off", "block"]
max_media_size_mb = 50
capabilities = ChannelCapabilities(
chat_types=["direct", "group", "topic_group"],
delivery_mode="direct",
reactions=True,
edit=True,
unsend=True,
reply=True,
media=True,
threads=True,
pin=True,
unpin=True,
list_pins=True,
supports_markdown=True,
supports_streaming=True,
streaming_modes=["off", "block"],
text_chunk_limit=TEXT_CHUNK_LIMIT,
max_media_size_mb=50,
)
meta = ChannelMeta(id="feishu", label="Feishu", aliases=["lark"])
webhook_path = "/api/channels/feishu/events"
_token_refresh_margin = 300
_http_timeout_ms: ClassVar[int] = 30000
_ws_ping_interval_s: ClassVar[int] = 30
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._status = ChannelStatus.DISCONNECTED
self._app_id: str = ""
self._app_secret: str = ""
self._bot_open_id: str = ""
self._platform: str = "feishu"
self._verify_token: str = ""
self._encrypt_key: str = ""
self._domain: str = ""
self._lark_client: Any = None
self._ws_client: Any = None
self._ws_task: asyncio.Task | None = None
self._ws_clients: dict[str, Any] = {}
self._ws_tasks: dict[str, asyncio.Task] = {}
self._account_clients: dict[str, Any] = {}
self._multi_account_mode: bool = False
self._token_refresh_task: asyncio.Task | None = None
self._connected_at: float | None = None
self._token_expire_at: float = 0
self._pending_streams: dict[str, str] = {}
self._stream_lock = asyncio.Lock()
self._stream_sessions: dict[str, FeishuStreamingSession] = {}
self._stream_backoff_until: dict[str, float] = {}
self._stream_context: dict[str, dict[str, str | None]] = {}
self._typing: TypingIndicator | None = None
self._ws_retry_count = 0
self._ws_base_delay = 1.0
self._ws_max_delay = 60.0
self._ws_jitter = 0.3
self._dedup = FeishuDedupStore(
persist_path=self.config.get("dedup_persist_path", ""),
)
self._chat_cache = ChatNameCache()
self._sequential = FeishuSequentialQueue()
self._reply_in_thread: bool = False
self._reaction_notifications: str = "all"
self._render_mode: str = "auto"
self._tts_config: FeishuTTSConfig = FeishuTTSConfig()
self._health_cache: tuple[float, Any] | None = None
self._health_cache_healthy_ttl = 600.0
self._health_cache_failed_ttl = 60.0
self._accounts = FeishuAccountManager(config)
self._client_cache = FeishuClientCache()
self._thread_bindings = ThreadBindingManager()
self._approval_adapter: FeishuApprovalAdapter | None = None
self._dynamic_agent = FeishuDynamicAgentManager()
self._group_session_scope: str = "group"
self._reactions_enabled: bool = True
self._tool_enabled: dict[str, bool] = {}
self._401_backoff_until: float = 0
self._401_retry_count: int = 0
self._401_max_retries: int = 3
self._401_base_delay: float = 1.0
self._401_max_delay: float = 30.0
async def _handle_401_backoff(self) -> None:
now = time.monotonic()
if self._401_backoff_until > now:
wait = self._401_backoff_until - now
logger.warning(f"[Feishu] 401 backoff active, waiting {wait:.1f}s")
await asyncio.sleep(wait)
async def _on_401_error(self) -> bool:
self._401_retry_count += 1
if self._401_retry_count > self._401_max_retries:
logger.error(f"[Feishu] 401 retry count exceeded ({self._401_max_retries})")
return False
delay = min(self._401_base_delay * (2 ** (self._401_retry_count - 1)), self._401_max_delay)
self._401_backoff_until = time.monotonic() + delay
logger.warning(
f"[Feishu] 401 error, refreshing token and retrying in {delay:.1f}s (attempt {self._401_retry_count})"
)
try:
await self._refresh_token()
except Exception as e:
logger.error(f"[Feishu] Token refresh after 401 failed: {e}")
return False
return True
def _reset_401_backoff(self) -> None:
if self._401_retry_count > 0:
logger.info("[Feishu] 401 backoff reset after successful request")
self._401_retry_count = 0
self._401_backoff_until = 0
async def _call_with_401_retry(self, callable_fn, *args, **kwargs) -> Any:
result = await callable_fn(*args, **kwargs)
if isinstance(result, DeliveryResult) and result.error_code == "auth_expired":
if await self._on_401_error():
result = await callable_fn(*args, **kwargs)
if isinstance(result, DeliveryResult) and not result.success:
return result
else:
return result
self._reset_401_backoff()
return result
async def connect(self) -> None:
if self._status == ChannelStatus.CONNECTED:
return
if not HAS_LARK_SDK:
raise ChannelAuthenticationError()
self._status = ChannelStatus.CONNECTING
logger.info(f"[Feishu] Starting channel '{self.config.get('name', self.channel_id)}'")
self._app_id = resolve_secret(self.config, "app_id", "FEISHU_APP_ID")
self._app_secret = resolve_secret(self.config, "app_secret", "FEISHU_APP_SECRET")
self._bot_open_id = self.config.get("bot_open_id", "")
self._platform = self.config.get("platform", "feishu")
self._verify_token = resolve_secret(self.config, "verify_token", "FEISHU_VERIFY_TOKEN")
self._encrypt_key = resolve_secret(self.config, "encrypt_key", "FEISHU_ENCRYPT_KEY")
self._reply_in_thread = self.config.get("replyInThread", False)
self._reaction_notifications = self.config.get("reactionNotifications", "all")
self._render_mode = self.config.get("renderMode", "auto")
self._tts_config = FeishuTTSConfig.from_config(self.config)
self._approval_adapter = FeishuApprovalAdapter.from_config(self.config)
self._dynamic_agent = FeishuDynamicAgentManager(FeishuDynamicAgentConfig.from_config(self.config))
self._group_session_scope = self.config.get("groupSessionScope", "group")
self._reactions_enabled = self.config.get("reactionsEnabled", True)
self._tool_enabled = {
"doc": self.config.get("tools", {}).get("doc", True),
"chat": self.config.get("tools", {}).get("chat", True),
"wiki": self.config.get("tools", {}).get("wiki", True),
"drive": self.config.get("tools", {}).get("drive", True),
"perm": self.config.get("tools", {}).get("perm", False),
}
self._ws_ping_interval_s = int(self.config.get("wsPingIntervalS", self._ws_ping_interval_s))
if not self._app_id or not self._app_secret:
raise ChannelAuthenticationError("app_id and app_secret are required")
self._webhook_path = self.config.get("webhookPath", self.webhook_path)
self._token_refresh_margin = self.config.get("tokenRefreshMargin", self._token_refresh_margin)
domain = self.config.get("domain", "")
if domain:
self._domain = domain
elif self._platform == "lark":
self._domain = "open.larksuite.com"
else:
self._domain = "open.feishu.cn"
timeout_ms_str = os.environ.get("FEISHU_HTTP_TIMEOUT_MS", "")
if timeout_ms_str and timeout_ms_str.isdigit():
self._http_timeout_ms = int(timeout_ms_str)
self._webhook_max_body_bytes = int(os.environ.get("FEISHU_WEBHOOK_MAX_BODY_BYTES", str(10 * 1024 * 1024)))
self._webhook_body_timeout_ms = int(os.environ.get("FEISHU_WEBHOOK_BODY_TIMEOUT_MS", "30000"))
http_proxy = self.config.get("httpProxy", "") or os.environ.get("FEISHU_HTTP_PROXY", "")
builder = lark_oapi.Client.builder().app_id(self._app_id).app_secret(self._app_secret).domain(self._domain)
if http_proxy:
builder.http_proxy(http_proxy)
self._lark_client = builder.build()
try:
token_resp = await asyncio.to_thread(self._lark_client.auth.tenant_access_token_internal)
except Exception as e:
raise ChannelAuthenticationError(str(e)) from e
if not token_resp.success():
raise ChannelAuthenticationError(f"Token acquisition failed: {token_resp.msg}")
expire = token_resp.data.get("expire", 0) if hasattr(token_resp, "data") else 0
self._token_expire_at = time.time() + expire
logger.info(f"[Feishu] Token acquired, expire in {expire}s")
self._start_token_refresh_loop()
enabled_accounts = self._accounts.list_enabled()
if len(enabled_accounts) > 1:
self._multi_account_mode = True
await self._start_multi_account_monitoring(enabled_accounts)
else:
await self._start_long_poll()
self._status = ChannelStatus.CONNECTED
self._connected_at = time.time()
logger.info(f"[Feishu] Channel started, platform: {self._platform}")
async def disconnect(self) -> None:
if self._status == ChannelStatus.DISCONNECTED:
return
logger.info(f"[Feishu] Stopping channel '{self.config.get('name', self.channel_id)}'")
if self._token_refresh_task and not self._token_refresh_task.done():
self._token_refresh_task.cancel()
try:
await self._token_refresh_task
except asyncio.CancelledError:
pass
self._token_refresh_task = None
if self._ws_task and not self._ws_task.done():
self._ws_task.cancel()
try:
await self._ws_task
except asyncio.CancelledError:
pass
self._ws_task = None
for name, task in list(self._ws_tasks.items()):
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._ws_tasks.pop(name, None)
self._ws_client = None
self._ws_clients.clear()
self._account_clients.clear()
self._multi_account_mode = False
self._lark_client = None
self._connected_at = None
self._token_expire_at = 0
async with self._stream_lock:
self._pending_streams.clear()
self._stream_context.clear()
for session in self._stream_sessions.values():
try:
await session.close()
except Exception:
pass
self._stream_sessions.clear()
self._stream_backoff_until.clear()
self._reset_401_backoff()
self._status = ChannelStatus.DISCONNECTED
def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage:
return normalize_inbound(self.channel_id, self.channel_type, raw, self._bot_open_id)
def format_outbound(self, response: ChannelResponse) -> dict[str, Any]:
content = response.content
chat_type = response.identity.metadata.get("chat_type", "private") if response.identity.metadata else "private"
return format_outbound(content, chat_type=chat_type, metadata=response.metadata)
async def send(self, response: ChannelResponse) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
await self._handle_401_backoff()
payload = self.format_outbound(response)
chat_id = response.identity.channel_chat_id
chat_type = (
response.identity.chat_type.value if isinstance(response.identity.chat_type, ChatType) else "private"
)
reply_to_msg_id = response.identity.metadata.get("reply_to_msg_id", "") if response.identity.metadata else ""
root_id = response.identity.metadata.get("root_id", "") if response.identity.metadata else ""
if reply_to_msg_id:
self._stream_context[chat_id] = {
"reply_to_msg_id": reply_to_msg_id,
"root_id": root_id or reply_to_msg_id,
}
content = payload.get("content", "")
buttons = payload.get("buttons")
thread_id = payload.get("thread_id")
template = payload.get("template")
tone = payload.get("tone")
use_post_format = payload.get("use_post_format", False)
if image_path := is_local_image_path(content):
try:
with open(image_path, "rb") as f:
image_data = f.read()
except OSError as e:
return DeliveryResult(success=False, error=f"Failed to read image file: {e}")
image_key = await upload_image(self._lark_client, image_data)
result = await self._send_image_msg(chat_id, image_key)
return self._check_401_result(result)
if chat_type in ("group", "thread") and reply_to_msg_id:
result = await _reply_message(
self._lark_client,
reply_to_msg_id,
content,
buttons=buttons,
thread_id=thread_id or (reply_to_msg_id if self._reply_in_thread else None),
use_post_format=use_post_format,
)
return self._check_401_result(result)
render_mode = dispatch_render(content, buttons=buttons, render_mode=self._render_mode)
if render_mode == "card":
url_unfurl = extract_urls(content)
result = await send_card(
self._lark_client,
chat_id,
content,
chat_type=chat_type,
buttons=buttons,
thread_id=thread_id,
template=template,
tone=tone,
url_unfurl=url_unfurl if url_unfurl else None,
)
else:
result = await send_text(
self._lark_client,
chat_id,
content,
chat_type=chat_type,
thread_id=thread_id,
use_post_format=use_post_format,
)
return self._check_401_result(result)
def _check_401_result(self, result: DeliveryResult) -> DeliveryResult:
if not result.success and result.error_code == "auth_expired":
asyncio.create_task(self._on_401_error())
else:
self._reset_401_backoff()
return result
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
account_id = self.config.get("account_id", "default")
now = time.monotonic()
backoff_until = self._stream_backoff_until.get(account_id, 0)
if backoff_until > now:
return DeliveryResult(success=True, message_id=msg_id or "pending")
async with self._stream_lock:
session = self._stream_sessions.get(chat_id)
if session is None and not finished:
ctx = self._stream_context.get(chat_id, {})
reply_to_msg_id = ctx.get("reply_to_msg_id") or None
root_id = ctx.get("root_id") or None
session = FeishuStreamingSession(
_client=self._lark_client,
_chat_id=chat_id,
_reply_to_msg_id=reply_to_msg_id,
_root_id=root_id,
_send_mode="reply" if reply_to_msg_id else ("root_create" if root_id else "create"),
)
self._stream_sessions[chat_id] = session
card_id = await session.start()
if not card_id:
self._stream_sessions.pop(chat_id, None)
self._stream_backoff_until[account_id] = now + STREAM_START_BACKOFF_S
fallback_result = await self._send_stream_fallback(chat_id, chunk, reply_to_msg_id, root_id)
logger.warning(f"[Feishu] Stream start failed for {chat_id}, fell back to static card")
return fallback_result
if session is None:
return DeliveryResult(success=True, message_id=msg_id or "pending")
if finished:
await session.close()
self._stream_context.pop(chat_id, None)
return DeliveryResult(success=True, message_id=session._message_id or msg_id)
await session.update(chunk)
return DeliveryResult(success=True, message_id=session._message_id or msg_id or "pending")
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
return await _send_reaction(self._lark_client, msg_id, emoji)
async def remove_reaction(self, chat_id: str, msg_id: str, reaction_id: str) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
return await _remove_reaction(self._lark_client, msg_id, reaction_id)
async def clear_all_reactions(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
return await clear_all_bot_reactions(self._lark_client, msg_id, self._bot_open_id)
async def list_reactions(self, chat_id: str, msg_id: str) -> dict[str, Any]:
if not self._lark_client:
return {"reactions": [], "total": 0}
return await _list_reactions(self._lark_client, msg_id)
async def read_message(self, msg_id: str) -> dict:
if not self._lark_client:
return {}
return await _read_message(self._lark_client, msg_id)
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
try:
if isinstance(data, bytes):
size_mb = len(data) / (1024 * 1024)
if size_mb > self.max_media_size_mb:
return DeliveryResult(
success=False,
error=f"Media size {size_mb:.1f}MB exceeds limit {self.max_media_size_mb}MB",
)
if media_type == "image":
image_key = await upload_image(self._lark_client, data)
return await self._send_image_msg(chat_id, image_key)
elif media_type in ("file", "audio", "video"):
filename = getattr(data, "filename", "file") if hasattr(data, "filename") else "file"
file_key = await upload_file(self._lark_client, data, filename)
return await self._send_media_msg(chat_id, file_key, media_type)
else:
return DeliveryResult(success=False, error=f"Unsupported media type: {media_type}")
except Exception as e:
logger.error(f"[Feishu] send_media failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def download_media(self, file_id: str) -> bytes:
if not self._lark_client:
raise RuntimeError("Not connected")
try:
info = json.loads(file_id)
except (json.JSONDecodeError, TypeError) as e:
raise ValueError(f"Invalid file_id format: {file_id}") from e
return await _download_media(
self._lark_client,
info["message_id"],
info["file_key"],
info["file_type"],
)
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
try:
content_json = json.dumps({"text": content}, ensure_ascii=False)
request = (
lark_oapi.api.im.v1.PatchMessageRequest.builder()
.message_id(msg_id)
.request_body(lark_oapi.api.im.v1.PatchMessageRequestBody.builder().content(content_json).build())
.build()
)
resp = await self._lark_client.im.v1.message.patch(request)
if resp.success():
return DeliveryResult(success=True, message_id=msg_id)
return DeliveryResult(success=False, error=f"Edit failed: {resp.msg}")
except Exception as e:
logger.error(f"[Feishu] edit_message failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
try:
request = lark_oapi.api.im.v1.DeleteMessageRequest.builder().message_id(msg_id).build()
resp = await self._lark_client.im.v1.message.delete(request)
if resp.success():
return DeliveryResult(success=True, message_id=msg_id)
return DeliveryResult(success=False, error=f"Delete failed: {resp.msg}")
except Exception as e:
logger.error(f"[Feishu] delete_message failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def update_card(self, msg_id: str, content: str) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
return await update_card_message(self._lark_client, msg_id, content)
async def _send_image_msg(self, chat_id: str, image_key: str) -> DeliveryResult:
content = json.dumps({"image_key": image_key}, ensure_ascii=False)
chat_type = "private"
if chat_id.startswith(CHAT_ID_PREFIX_GROUP):
chat_type = "group"
receive_id = chat_id.replace(CHAT_ID_PREFIX_DM, "").replace(CHAT_ID_PREFIX_GROUP, "")
receive_id_type = "open_id" if chat_type == "private" else "chat_id"
request = (
lark_oapi.api.im.v1.CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
lark_oapi.api.im.v1.CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type("image")
.content(content)
.build()
)
.build()
)
resp = await self._lark_client.im.message.create(request)
if resp.success():
return DeliveryResult(success=True, message_id=resp.data.get("message_id", ""))
return DeliveryResult(success=False, error=f"Send image failed: {resp.msg}")
async def _send_stream_fallback(
self, chat_id: str, content: str, reply_to_msg_id: str | None, root_id: str | None
) -> DeliveryResult:
chat_type = "group" if chat_id.startswith(CHAT_ID_PREFIX_GROUP) else "private"
receive_id = chat_id.replace(CHAT_ID_PREFIX_DM, "").replace(CHAT_ID_PREFIX_GROUP, "")
if reply_to_msg_id:
return await _reply_message(
self._lark_client,
reply_to_msg_id,
content,
thread_id=root_id,
)
return await send_card(
self._lark_client,
receive_id,
content,
chat_type=chat_type,
thread_id=root_id,
)
async def _send_media_msg(self, chat_id: str, file_key: str, media_type: str) -> DeliveryResult:
msg_type_map = {"audio": "audio", "video": "media", "file": "file"}
feishu_msg_type = msg_type_map.get(media_type, "file")
content = json.dumps({"file_key": file_key}, ensure_ascii=False)
chat_type = "private"
if chat_id.startswith(CHAT_ID_PREFIX_GROUP):
chat_type = "group"
receive_id = chat_id.replace(CHAT_ID_PREFIX_DM, "").replace(CHAT_ID_PREFIX_GROUP, "")
receive_id_type = "open_id" if chat_type == "private" else "chat_id"
request = (
lark_oapi.api.im.v1.CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
lark_oapi.api.im.v1.CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type(feishu_msg_type)
.content(content)
.build()
)
.build()
)
resp = await self._lark_client.im.message.create(request)
if resp.success():
return DeliveryResult(success=True, message_id=resp.data.get("message_id", ""))
return DeliveryResult(success=False, error=f"Send {media_type} failed: {resp.msg}")
async def health_check(self) -> HealthStatus:
if not self._lark_client:
return HealthStatus(status="unhealthy", last_error="Not connected")
now = time.monotonic()
if self._health_cache is not None:
cached_at, cached_result = self._health_cache
ttl = self._health_cache_healthy_ttl if cached_result.status == "healthy" else self._health_cache_failed_ttl
if now - cached_at < ttl:
return cached_result
try:
resp = self._lark_client.bot.v3.info()
if not resp.success():
result = HealthStatus(status="degraded", last_error=f"Bot probe: {resp.msg}")
self._health_cache = (now, result)
return result
result = HealthStatus(
status="healthy",
metadata={
"app_id": self._app_id,
"platform": self._platform,
"adapter_status": self._status.value,
},
last_connected_at=utc_now_naive(),
)
self._health_cache = (now, result)
return result
except Exception as e:
result = HealthStatus(status="unhealthy", last_error=str(e))
self._health_cache = (now, result)
return result
async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool:
return verify_feishu_signature(headers, body, self._encrypt_key)
async def verify_and_decrypt_webhook(
self, headers: dict, body: bytes, source_ip: str = "default"
) -> tuple[bool, bytes | None, str]:
return verify_and_decrypt_webhook(headers, body, self._encrypt_key, source_ip)
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
if not self._lark_client:
return {}
try:
request = (
lark_oapi.api.contact.v3.GetUserRequest.builder()
.user_id(channel_user_id)
.user_id_type("open_id")
.build()
)
resp = self._lark_client.contact.v3.user.get(request)
if resp.success():
user = resp.data.user if hasattr(resp.data, "user") else {}
avatar_info = getattr(user, "avatar", {}) or {}
return {
"open_id": getattr(user, "open_id", ""),
"user_id": getattr(user, "user_id", ""),
"union_id": getattr(user, "union_id", ""),
"name": getattr(user, "name", ""),
"en_name": getattr(user, "en_name", ""),
"nickname": getattr(user, "nickname", ""),
"email": getattr(user, "email", ""),
"enterprise_email": getattr(user, "enterprise_email", ""),
"mobile": getattr(user, "mobile", ""),
"mobile_visible": getattr(user, "mobile_visible", False),
"avatar_url": avatar_info.get("avatar_240", ""),
"department_ids": getattr(user, "department_ids", []),
"job_title": getattr(user, "job_title", ""),
"employee_no": getattr(user, "employee_no", ""),
"status": getattr(user, "status", None),
"leader_user_id": getattr(user, "leader_user_id", ""),
"city": getattr(user, "city", ""),
"country": getattr(user, "country", ""),
"work_station": getattr(user, "work_station", ""),
}
return {}
except Exception as e:
logger.warning(f"[Feishu] get_user_info failed for {channel_user_id}: {e}")
return {}
async def get_channel_info(self, chat_id: str, *, include_members: bool = False) -> dict:
if not self._lark_client:
return {}
try:
request = lark_oapi.api.im.v1.GetChatRequest.builder().chat_id(chat_id).build()
resp = self._lark_client.im.v1.chat.get(request)
if resp.success():
chat = resp.data if hasattr(resp.data, "chat") else resp.data
return {
"chat_id": getattr(chat, "chat_id", chat_id),
"name": getattr(chat, "name", ""),
"description": getattr(chat, "description", ""),
"owner_id": getattr(chat, "owner_id", ""),
"owner_id_type": getattr(chat, "owner_id_type", ""),
"member_count": getattr(chat, "member_count", 0),
"chat_type": getattr(chat, "chat_type", ""),
}
return {}
except Exception as e:
logger.warning(f"[Feishu] get_channel_info failed for {chat_id}: {e}")
return {}
async def list_channels(self, *, scope: str = "all") -> list[dict]:
return await _list_groups_feishu(self._lark_client)
async def pin_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
return await _create_pin(self._lark_client, msg_id)
async def unpin_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
return await _remove_pin(self._lark_client, msg_id)
async def list_pins(self, chat_id: str) -> dict[str, Any]:
if not self._lark_client:
return {"pins": [], "total": 0}
return await _list_pins(self._lark_client, chat_id)
async def start_typing(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
if self._typing is None:
self._typing = TypingIndicator(_client=self._lark_client)
account_id = self.config.get("account_id", "default")
ok = await self._typing.start(msg_id, account_id)
return DeliveryResult(success=ok)
async def stop_typing(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._lark_client or self._typing is None:
return DeliveryResult(success=False, error="Not connected")
ok = await self._typing.stop(msg_id)
return DeliveryResult(success=ok)
async def list_peers(self) -> list[dict]:
if not self._lark_client:
return []
return await _list_peers_feishu(self._lark_client)
async def ping(self) -> dict[str, Any]:
if not self._lark_client:
return {"status": "disconnected", "latency_ms": None}
t0 = time.monotonic()
try:
resp = self._lark_client.bot.v3.info()
latency_ms = round((time.monotonic() - t0) * 1000, 1)
if resp.success():
return {
"status": "healthy",
"latency_ms": latency_ms,
"app_id": self._app_id,
"platform": self._platform,
"adapter_status": self._status.value,
}
return {
"status": "degraded",
"latency_ms": latency_ms,
"error": resp.msg if hasattr(resp, "msg") else str(resp),
}
except Exception as e:
latency_ms = round((time.monotonic() - t0) * 1000, 1)
return {"status": "unreachable", "latency_ms": latency_ms, "error": str(e)}
async def send_tts(self, chat_id: str, text: str) -> bool:
if not self._lark_client:
return False
return await send_tts_audio(
self._lark_client,
chat_id,
text,
tts_config=self._tts_config,
)
async def _start_multi_account_monitoring(self, accounts: list) -> None:
for account in accounts:
account_client = (
lark_oapi.Client.builder()
.app_id(account.app_id)
.app_secret(account.app_secret)
.domain(account.domain or self._domain)
.build()
)
self._account_clients[account.name] = account_client
try:
ws_client = lark_oapi.ws.Client(
app_id=account.app_id,
app_secret=account.app_secret,
event_handler=self._make_account_event_handler(account.name),
log_level=30,
)
except Exception as e:
logger.error(f"[Feishu] Failed to init WS client for account '{account.name}': {e}")
continue
self._ws_clients[account.name] = ws_client
self._ws_tasks[account.name] = asyncio.create_task(
self._account_ws_runner(account.name, ws_client, account)
)
logger.info(f"[Feishu] Started WS monitoring for account '{account.name}'")
def _make_account_event_handler(self, account_name: str):
async def handler(event: dict) -> None:
await self._handle_ws_event_for_account(event, account_name)
return handler
async def _handle_ws_event_for_account(self, event: dict, account_name: str) -> None:
account = self._accounts.get_account(account_name)
if account is None or self._message_handler is None:
return
event_type = event.get("type", "")
if self._reaction_notifications != "all":
if event_type in ("im.message.reaction.created_v1", "im.message.reaction.deleted_v1"):
if self._should_skip_reaction_event(event):
return
supported = {
"im.message.receive_v1",
"im.message.message_read_v1",
"im.message.updated_v1",
"im.message.deleted_v1",
"card.action.trigger",
"card.action.trigger_v1",
"im.message.reaction.created_v1",
"im.message.reaction.deleted_v1",
"im.chat.member.bot.added_v1",
"im.chat.member.bot.deleted_v1",
"application.bot.menu_v6",
"drive.notice.comment_add_v1",
}
if event_type not in supported:
return
raw_payload = {
"event": event.get("event", {}),
"event_type": event_type,
}
if self._dedup.has_processed(raw_payload):
return
self._dedup.record_processed(raw_payload)
try:
if event_type == "application.bot.menu_v6":
channel_msg = self._build_menu_message(event)
elif event_type == "drive.notice.comment_add_v1":
channel_msg = handle_comment_event(
self.channel_id,
self.channel_type,
raw_payload,
account.bot_open_id,
)
if channel_msg is None:
return
elif event_type in ("card.action.trigger", "card.action.trigger_v1"):
channel_msg = self._handle_card_action_event(raw_payload, account.bot_open_id)
if channel_msg is None:
return
else:
channel_msg = normalize_inbound(
self.channel_id,
self.channel_type,
raw_payload,
account.bot_open_id,
)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.get_event_loop()
key = channel_msg.identity.channel_chat_id or "default"
asyncio.run_coroutine_threadsafe(
self._sequential.run_sequential(key, self._message_handler(channel_msg)),
loop,
)
except Exception as e:
logger.error(f"[Feishu:{account_name}] Event [{event_type}] error: {e}")
finally:
self._dedup.finalize_processing(raw_payload)
async def _account_ws_runner(self, name: str, ws_client: Any, account: Any) -> None:
retry_count = 0
while self._status == ChannelStatus.CONNECTED:
try:
await asyncio.to_thread(ws_client.start)
except Exception as e:
retry_count += 1
delay = min(self._ws_base_delay * (2 ** (retry_count - 1)), self._ws_max_delay)
logger.warning(
f"[Feishu:{name}] WS disconnected (attempt {retry_count}), reconnecting in {delay:.1f}s: {e}"
)
if self._status != ChannelStatus.CONNECTED:
break
await asyncio.sleep(delay)
try:
ws_client = lark_oapi.ws.Client(
app_id=account.app_id,
app_secret=account.app_secret,
event_handler=self._make_account_event_handler(name),
log_level=30,
)
self._ws_clients[name] = ws_client
except Exception as init_err:
logger.error(f"[Feishu:{name}] WS re-init failed: {init_err}")
async def _start_long_poll(self) -> None:
if not HAS_LARK_SDK:
raise ChannelConnectionError("lark-oapi SDK not installed")
self._inject_event_loop_proxy()
try:
self._ws_client = lark_oapi.ws.Client(
app_id=self._app_id,
app_secret=self._app_secret,
event_handler=self._handle_ws_event,
log_level=30,
)
except Exception as e:
raise ChannelConnectionError(f"Failed to init WS client: {e}") from e
if hasattr(self._ws_client, "start"):
self._ws_task = asyncio.create_task(self._ws_runner())
logger.info("[Feishu] Long Poll started with auto-reconnect")
else:
raise ChannelConnectionError("WS client has no 'start' method")
async def _ws_runner(self) -> None:
while self._status == ChannelStatus.CONNECTED:
try:
await asyncio.to_thread(self._ws_client.start)
except Exception as e:
self._ws_retry_count += 1
delay = min(
self._ws_base_delay * (2 ** (self._ws_retry_count - 1)),
self._ws_max_delay,
)
jitter = delay * self._ws_jitter * (0.5 - asyncio.get_event_loop().time() % 1)
delay += jitter
logger.warning(
f"[Feishu] WS disconnected (attempt {self._ws_retry_count}), reconnecting in {delay:.1f}s: {e}"
)
if self._status != ChannelStatus.CONNECTED:
break
await asyncio.sleep(delay)
try:
self._ws_client = lark_oapi.ws.Client(
app_id=self._app_id,
app_secret=self._app_secret,
event_handler=self._handle_ws_event,
log_level=30,
)
except Exception as init_err:
logger.error(f"[Feishu] WS re-init failed: {init_err}")
self._ws_retry_count = 0
def _start_token_refresh_loop(self) -> None:
self._token_refresh_task = asyncio.create_task(self._token_refresh_loop())
async def _token_refresh_loop(self) -> None:
logger.info("[Feishu] Token refresh loop started")
while self._status == ChannelStatus.CONNECTED:
remaining = self._token_expire_at - time.time()
if remaining <= 0:
logger.warning("[Feishu] Token already expired, skipping refresh")
break
delay = max(remaining - self._token_refresh_margin, 10)
logger.debug(f"[Feishu] Next token refresh in {delay:.0f}s")
try:
await asyncio.sleep(delay)
await self._refresh_token()
except asyncio.CancelledError:
logger.info("[Feishu] Token refresh loop cancelled")
return
except Exception as e:
logger.error(f"[Feishu] Token refresh failed: {e}")
async def _refresh_token(self) -> None:
if not self._lark_client:
return
try:
resp = await asyncio.to_thread(self._lark_client.auth.tenant_access_token_internal)
except Exception as e:
logger.error(f"[Feishu] Token refresh call failed: {e}")
return
if not resp.success():
logger.error(f"[Feishu] Token refresh failed: {resp.msg}")
return
expire = resp.data.get("expire", 0) if hasattr(resp, "data") else 0
self._token_expire_at = time.time() + expire
logger.info(f"[Feishu] Token refreshed, expire in {expire}s")
@staticmethod
def _inject_event_loop_proxy() -> None:
import lark_oapi.ws.client as _ws_mod
class _EventLoopProxy:
def __getattr__(self, name):
loop = asyncio.get_running_loop()
return getattr(loop, name)
if not isinstance(getattr(_ws_mod, "loop", None), _EventLoopProxy):
_ws_mod.loop = _EventLoopProxy()
async def _handle_ws_event(self, event: dict) -> None:
if self._message_handler is None:
return
event_type = event.get("type", "")
if self._reaction_notifications != "all":
if event_type in ("im.message.reaction.created_v1", "im.message.reaction.deleted_v1"):
if self._should_skip_reaction_event(event):
return
supported = {
"im.message.receive_v1",
"im.message.message_read_v1",
"im.message.updated_v1",
"im.message.deleted_v1",
"card.action.trigger",
"card.action.trigger_v1",
"im.message.reaction.created_v1",
"im.message.reaction.deleted_v1",
"im.chat.member.bot.added_v1",
"im.chat.member.bot.deleted_v1",
"application.bot.menu_v6",
"drive.notice.comment_add_v1",
}
if event_type not in supported:
return
raw_payload = {
"event": event.get("event", {}),
"event_type": event_type,
}
if self._dedup.has_processed(raw_payload):
logger.debug(f"[Feishu] Dedup: skipping event_id={event.get('event_id', '')}")
return
self._dedup.record_processed(raw_payload)
try:
if event_type == "application.bot.menu_v6":
channel_msg = self._build_menu_message(event)
elif event_type == "drive.notice.comment_add_v1":
channel_msg = handle_comment_event(
self.channel_id,
self.channel_type,
raw_payload,
self._bot_open_id,
)
if channel_msg is None:
return
elif event_type in ("card.action.trigger", "card.action.trigger_v1"):
channel_msg = self._handle_card_action_event(raw_payload, self._bot_open_id)
if channel_msg is None:
return
else:
channel_msg = self.normalize_inbound(raw_payload)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.get_event_loop()
key = channel_msg.identity.channel_chat_id or "default"
asyncio.run_coroutine_threadsafe(
self._sequential.run_sequential(key, self._message_handler(channel_msg)),
loop,
)
except Exception as e:
logger.error(f"[Feishu] Event [{event_type}] error: {e}")
finally:
self._dedup.finalize_processing(raw_payload)
def _should_skip_reaction_event(self, event: dict) -> bool:
if not self._reactions_enabled:
return True
if self._reaction_notifications == "off":
return True
if self._reaction_notifications == "own":
event_data = event.get("event", {})
user_id = event_data.get("user_id", {}) or {}
operator_id = user_id.get("open_id", "")
return operator_id != self._bot_open_id
return False
def _handle_card_action_event(self, raw_payload: dict, bot_open_id: str) -> Any:
event = raw_payload.get("event", {})
action_value = event.get("action", {}).get("value", "")
open_id = event.get("operator", {}).get("open_id", "") or event.get("open_id", "")
action_type, envelope = decode_card_action(action_value)
if action_type == "invalid:malformed":
logger.warning("[Feishu] Card action decode failed: malformed value")
return None
if action_type == "legacy":
logger.warning("[Feishu] Legacy card command detected, ignoring")
return None
if envelope is not None:
chat_id = event.get("open_chat_id", "") or event.get("chat_id", "")
is_valid = validate_card_context(
envelope, open_id, chat_id, "group" if chat_id.startswith("oc_") else "private"
)
if not is_valid:
logger.warning("[Feishu] Card action context validation failed")
return None
return normalize_inbound(
self.channel_id,
self.channel_type,
raw_payload,
bot_open_id,
)
def is_tool_enabled(self, tool_name: str) -> bool:
return self._tool_enabled.get(tool_name, True)
def _build_menu_message(self, event: dict) -> Any:
parsed = parse_bot_menu_event(event)
if parsed is None:
return normalize_inbound(
self.channel_id,
self.channel_type,
{"event": event.get("event", {}), "event_type": "application.bot.menu_v6"},
self._bot_open_id,
)
open_id = parsed["open_id"]
chat_id = f"{CHAT_ID_PREFIX_DM}{open_id}"
return build_synthetic_message(
self.channel_id,
self.channel_type,
open_id,
chat_id,
parsed["command"],
)
async def auth_login(self, ctx: dict[str, Any] | None = None) -> dict[str, Any]:
if not HAS_LARK_SDK:
return {"success": False, "error": "lark-oapi SDK not available"}
if not self._app_id or not self._app_secret:
return {"success": False, "error": "app_id and app_secret not configured"}
from .oauth import FeishuOAuthClient
redirect_uri = self.config.get("oauth_redirect_uri", "")
oauth_client = FeishuOAuthClient(
app_id=self._app_id,
app_secret=self._app_secret,
redirect_uri=redirect_uri,
)
ctx = ctx or {}
state = ctx.get("state", "")
scope = ctx.get("scope", "user:read")
auth_url = oauth_client.get_authorization_url(state=state, scope=scope)
return {
"success": True,
"auth_url": auth_url,
"provider": "feishu",
"channel_id": self.channel_id,
}
async def handle_auth_callback(self, code: str) -> dict[str, Any]:
if not HAS_LARK_SDK:
return {"success": False, "error": "lark-oapi SDK not available"}
from .oauth import FeishuOAuthClient
oauth_client = FeishuOAuthClient(
app_id=self._app_id,
app_secret=self._app_secret,
redirect_uri=self.config.get("oauth_redirect_uri", ""),
)
token_data = await oauth_client.exchange_code_for_token(code)
if not token_data:
return {"success": False, "error": "Token exchange failed"}
return {
"success": True,
"provider": "feishu",
"channel_id": self.channel_id,
"access_token": token_data.get("access_token", ""),
"refresh_token": token_data.get("refresh_token", ""),
"expires_in": token_data.get("expires_in", 0),
"open_id": token_data.get("open_id", ""),
"name": token_data.get("name", ""),
}