ForcePilot/backend/package/yuxi/channels/adapters/feishu/adapter.py
Kris 69b7139863 refactor(feishu): 清理冗余代码并补全飞书工具集实现
1.  移除多个文件中的空行、冗余导入
2.  修复文件末尾缺少换行符的问题
3.  新增并补全飞书多类工具API实现:
    - 多维表格:更新、删除记录,列出视图
    - 文档:更新、追加、删除块
    - 云文档:上传、下载文件
    - 群组:创建、添加成员、更新信息、创建公告
    - 目录:重构用户部门缓存逻辑
4.  优化消息发送、回复、转发等API的错误处理和逻辑
5.  新增消息列表查询、已读状态查询等功能
2026-05-13 16:07:59 +08:00

1456 lines
57 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.exceptions import (
ChannelAuthenticationError,
ChannelConnectionError,
)
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.meta import ChannelMeta
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 .accounts import FeishuAccountManager
from .approval import FeishuApprovalAdapter
from .card_action import decode_card_action, validate_card_context
from .cards import TEXT_CHUNK_LIMIT
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
from .directory import list_peers as _list_peers_feishu
from .dynamic_agent import FeishuDynamicAgentConfig, FeishuDynamicAgentManager
from .formatter import format_outbound
from .media import (
download_media as _download_media,
)
from .media import (
upload_file,
upload_image,
)
from .normalizer import normalize_inbound
from .oauth import TokenBackoffManager
from .pins import create_pin as _create_pin
from .pins import list_pins as _list_pins
from .pins import remove_pin as _remove_pin
from .reactions import (
clear_all_bot_reactions,
)
from .reactions import (
list_reactions as _list_reactions,
)
from .reactions import (
remove_reaction as _remove_reaction,
)
from .reply_dispatcher import dispatch_render, extract_urls
from .secret_resolver import resolve_secret
from .send import (
forward_message as _forward_message,
)
from .send import (
get_message_read_status as _get_message_read_status,
)
from .send import (
is_local_image_path,
send_card,
send_text,
update_card_message,
)
from .send import (
list_messages as _list_messages,
)
from .send import (
read_message as _read_message,
)
from .send import (
reply_message as _reply_message,
)
from .send import (
send_reaction as _send_reaction,
)
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,
forward=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._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="feishu")
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 = TokenBackoffManager(max_retries=3, base_delay=1.0, max_delay=30.0)
self._token_refresh_lock = asyncio.Lock()
async def _handle_401_backoff(self) -> None:
await self._401_backoff.wait_if_backing_off()
async def _on_401_error(self) -> bool:
if not self._401_backoff.record_error():
return False
async with self._token_refresh_lock:
if self._token_expire_at > time.time() + self._token_refresh_margin:
return True
try:
return await self._refresh_token()
except Exception as e:
logger.error(f"[Feishu] Token refresh after 401 failed: {e}")
return False
def _reset_401_backoff(self) -> None:
self._401_backoff.reset()
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}")
data = token_resp.data if hasattr(token_resp, "data") else {}
token_value = data.get("tenant_access_token", "")
if not token_value:
raise ChannelAuthenticationError("Token acquisition succeeded but returned empty token value")
expire = data.get("expire", 0)
if expire <= 0:
raise ChannelAuthenticationError(f"Token acquisition returned invalid expire: {expire}")
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()
async def _do_send() -> DeliveryResult:
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"
)
identity_meta = response.identity.metadata or {}
reply_to_msg_id = identity_meta.get("reply_to_msg_id", "")
root_id = identity_meta.get("root_id", "")
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:
raise ChannelConnectionError(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 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,
chat_type=chat_type,
chat_id=chat_id,
)
return 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 result
try:
result = await self._circuit_breaker.call(_do_send)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
if not result.success and result.error_code == "auth_expired":
if await self._on_401_error():
try:
result = await self._circuit_breaker.call(_do_send)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
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 list_messages(
self,
chat_id: str,
page_token: str = "",
page_size: int = 20,
start_time: str = "",
end_time: str = "",
) -> dict[str, Any]:
if not self._lark_client:
return {"messages": [], "has_more": False, "page_token": ""}
return await _list_messages(
self._lark_client,
chat_id,
page_token=page_token,
page_size=page_size,
start_time=start_time,
end_time=end_time,
)
async def forward_message(
self,
message_id: str,
target_chat_id: str,
target_type: str = "chat",
) -> DeliveryResult:
if not self._lark_client:
return DeliveryResult(success=False, error="Not connected")
return await _forward_message(
self._lark_client,
message_id,
target_chat_id,
target_type=target_type,
)
async def get_message_read_status(
self,
message_id: str,
page_token: str = "",
page_size: int = 20,
) -> dict[str, Any]:
if not self._lark_client:
return {"users": [], "has_more": False, "page_token": ""}
return await _get_message_read_status(
self._lark_client,
message_id,
page_token=page_token,
page_size=page_size,
)
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,
chat_type=chat_type,
chat_id=receive_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 expired, attempting immediate refresh")
else:
delay = max(remaining - self._token_refresh_margin, 10)
logger.debug(f"[Feishu] Next token refresh in {delay:.0f}s")
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
logger.info("[Feishu] Token refresh loop cancelled")
return
try:
if not await self._refresh_token():
logger.warning("[Feishu] Token refresh failed, retrying in 30s")
await asyncio.sleep(30)
except asyncio.CancelledError:
logger.info("[Feishu] Token refresh loop cancelled")
return
except Exception as e:
logger.error(f"[Feishu] Token refresh unexpected error: {e}, retrying in 30s")
await asyncio.sleep(30)
async def _refresh_token(self) -> bool:
if not self._lark_client:
return False
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 False
if not resp.success():
logger.error(f"[Feishu] Token refresh failed: {resp.msg}")
return False
data = resp.data if hasattr(resp, "data") else {}
token_value = data.get("tenant_access_token", "")
if not token_value:
logger.error("[Feishu] Token refresh returned empty token value")
return False
expire = data.get("expire", 0)
if expire <= 0:
logger.error(f"[Feishu] Token refresh returned invalid expire: {expire}")
return False
self._token_expire_at = time.time() + expire
logger.info(f"[Feishu] Token refreshed, expire in {expire}s")
return True
@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", ""),
}
def list_account_ids(self) -> list[str]:
return self._accounts.list_account_ids()
def resolve_account(self, account_id: str | None = None) -> dict | None:
account = self._accounts.resolve_account(account_id)
if account is None:
return None
return {"account_id": account.account_id, "label": account.label, "enabled": account.enabled}
def default_account_id(self) -> str:
return self._accounts.default_account_id or ""
async def add_account(self, account_id: str, config: dict[str, Any]) -> bool:
if account_id in self._accounts.accounts:
logger.warning(f"[Feishu] Account '{account_id}' already exists")
return False
from .accounts import FeishuAccount
new_account = FeishuAccount(
account_id=account_id,
label=config.get("label", account_id),
app_id=config.get("app_id", ""),
app_secret=config.get("app_secret", ""),
verification_token=config.get("verification_token", ""),
encrypt_key=config.get("encrypt_key", ""),
bot_open_id=config.get("bot_open_id", ""),
enabled=True,
config=config,
)
self._accounts.accounts[account_id] = new_account
if not self._accounts.default_account_id:
self._accounts.default_account_id = account_id
logger.info(f"[Feishu] Added account '{account_id}'")
return True
async def remove_account(self, account_id: str) -> bool:
return self._accounts.delete_account(account_id)
async def delete_account(self, account_id: str) -> bool:
return self._accounts.delete_account(account_id)
async def update_account(self, account_id: str, config: dict[str, Any]) -> bool:
account = self._accounts.accounts.get(account_id)
if account is None:
logger.warning(f"[Feishu] Account '{account_id}' not found")
return False
account.label = config.get("label", account.label)
account.app_id = config.get("app_id", account.app_id)
account.app_secret = config.get("app_secret", account.app_secret)
account.verification_token = config.get("verification_token", account.verification_token)
account.encrypt_key = config.get("encrypt_key", account.encrypt_key)
account.bot_open_id = config.get("bot_open_id", account.bot_open_id)
account.enabled = config.get("enabled", account.enabled)
account.config = config
logger.info(f"[Feishu] Updated account '{account_id}'")
return True
def build_channel_summary(self) -> dict[str, Any]:
enabled = self._accounts.list_enabled()
return {
"channel_id": self.channel_id,
"channel_type": self.channel_type,
"account_count": len(self._accounts.accounts),
"enabled_count": len(enabled),
"connected": self._status == ChannelStatus.CONNECTED,
}
def build_account_snapshot(self, account_id: str) -> dict[str, Any] | None:
account = self._accounts.accounts.get(account_id)
if account is None:
return None
return {
"account_id": account.account_id,
"label": account.label,
"enabled": account.enabled,
"has_app_id": bool(account.app_id),
"has_secret": bool(account.app_secret),
"has_verification_token": bool(account.verification_token),
}