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

310 lines
10 KiB
Python

from __future__ import annotations
import json
import logging
import os
from typing import Any
from yuxi.channels.models import DeliveryResult
from .cards import build_feishu_card, build_feishu_post_content, build_feishu_text_content
logger = logging.getLogger(__name__)
try:
import lark_oapi
HAS_LARK_SDK = True
except ImportError:
HAS_LARK_SDK = False
lark_oapi = None # type: ignore
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
REPLY_DEGRADE_CODES = {230011, 231003}
def _resolve_receive_id_type(chat_type: str) -> str:
if chat_type in ("direct", "private", "p2p"):
return "open_id"
if chat_type in ("group", "thread"):
return "chat_id"
return "open_id"
async def reply_message(
client: Any,
message_id: str,
content: str,
*,
buttons: list[dict[str, str]] | None = None,
thread_id: str | None = None,
use_post_format: bool = False,
silent: bool = False,
) -> DeliveryResult:
if not HAS_LARK_SDK:
return DeliveryResult(success=False, error="lark-oapi SDK not available")
try:
if buttons:
card = build_feishu_card(content, buttons=buttons)
msg_content = json.dumps(card, ensure_ascii=False)
msg_type = "interactive"
elif use_post_format:
msg_content = json.dumps(build_feishu_post_content(content), ensure_ascii=False)
msg_type = "post"
else:
msg_content = json.dumps(build_feishu_text_content(content), ensure_ascii=False)
msg_type = "text"
request_body = (
lark_oapi.api.im.v1.ReplyMessageRequestBody.builder().content(msg_content).msg_type(msg_type).build()
)
request = (
lark_oapi.api.im.v1.ReplyMessageRequest.builder().message_id(message_id).request_body(request_body).build()
)
if thread_id:
request.root_id = thread_id
resp = await client.im.v1.message.reply(request)
if resp.success():
return DeliveryResult(success=True, message_id=resp.data.get("message_id", ""))
error_code = getattr(resp, "code", 0)
if error_code in REPLY_DEGRADE_CODES:
logger.info(f"[Feishu] Reply target revoked (code={error_code}), falling back to direct send")
return await send_text(
client,
message_id,
content,
chat_type="private",
thread_id=thread_id,
)
return DeliveryResult(success=False, error=f"Feishu API error: {resp.msg}")
except AttributeError as e:
return DeliveryResult(success=False, error=f"SDK error: {e}")
except Exception as e:
logger.error(f"Feishu reply_message failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_text(
client: Any,
receive_id: str,
content: str,
*,
chat_type: str = "private",
thread_id: str | None = None,
use_post_format: bool = False,
silent: bool = False,
) -> DeliveryResult:
if not HAS_LARK_SDK:
return DeliveryResult(success=False, error="lark-oapi SDK not available")
try:
if use_post_format:
content_json = build_feishu_post_content(content)
msg_type = "post"
else:
content_json = build_feishu_text_content(content)
msg_type = "text"
msg_content = json.dumps(content_json, ensure_ascii=False)
request = _make_create_msg_request(receive_id, chat_type, msg_type, msg_content)
if thread_id:
request.root_id = thread_id
resp = await client.im.message.create(request)
if resp.success():
return DeliveryResult(success=True, message_id=resp.data.get("message_id", ""))
code = getattr(resp, "code", -1)
msg = getattr(resp, "msg", str(resp))
if code == 401:
return DeliveryResult(success=False, error=f"Auth failed: {msg}", error_code="auth_expired")
return DeliveryResult(success=False, error=f"Feishu API error: {msg}")
except AttributeError as e:
return DeliveryResult(success=False, error=f"SDK error: {e}")
except Exception as e:
logger.error(f"Feishu send_text failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def read_message(client: Any, message_id: str) -> dict:
if not HAS_LARK_SDK:
return {}
try:
request = lark_oapi.api.im.v1.GetMessageRequest.builder().message_id(message_id).build()
resp = await client.im.v1.message.get(request)
if resp.success():
items = resp.data.get("items", [])
if items:
item = items[0]
return {
"message_id": item.get("message_id", ""),
"msg_type": item.get("msg_type", ""),
"chat_id": item.get("chat_id", ""),
"chat_type": item.get("chat_type", ""),
"content": item.get("body", {}).get("content", ""),
"root_id": item.get("root_id", ""),
"parent_id": item.get("parent_id", ""),
"sender_id": item.get("sender", {}).get("id", ""),
"create_time": item.get("create_time", ""),
"mentions": item.get("mentions", []),
}
return {}
except Exception as e:
logger.error(f"[Feishu] read_message failed: {e}")
return {}
async def send_card(
client: Any,
receive_id: str,
content: str,
*,
chat_type: str = "private",
title: str = "AI 助手",
buttons: list[dict[str, str]] | None = None,
streaming: bool = False,
thread_id: str | None = None,
raw_card: bool = False,
template: str | None = None,
tone: str | None = None,
url_unfurl: list[str] | None = None,
silent: bool = False,
) -> DeliveryResult:
if not HAS_LARK_SDK:
return DeliveryResult(success=False, error="lark-oapi SDK not available")
try:
if raw_card:
msg_content = content
else:
card = build_feishu_card(
content,
title=title,
buttons=buttons,
streaming=streaming,
template=template,
tone=tone,
url_unfurl=url_unfurl,
)
msg_content = json.dumps(card, ensure_ascii=False)
request = _make_create_msg_request(receive_id, chat_type, "interactive", msg_content)
if thread_id:
request.root_id = thread_id
resp = await client.im.message.create(request)
if resp.success():
return DeliveryResult(success=True, message_id=resp.data.get("message_id", ""))
code = getattr(resp, "code", -1)
msg = getattr(resp, "msg", str(resp))
if code == 401:
return DeliveryResult(success=False, error=f"Auth failed: {msg}", error_code="auth_expired")
return DeliveryResult(success=False, error=f"Feishu API error: {msg}")
except AttributeError as e:
return DeliveryResult(success=False, error=f"SDK error: {e}")
except Exception as e:
logger.error(f"Feishu send_card failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_reaction(
client: Any,
message_id: str,
reaction: str,
*,
chat_type: str = "private",
) -> DeliveryResult:
if not HAS_LARK_SDK:
return DeliveryResult(success=False, error="lark-oapi SDK not available")
try:
req = (
lark_oapi.api.im.v1.CreateMessageReactionRequest.builder()
.message_id(message_id)
.request_body(
lark_oapi.api.im.v1.CreateMessageReactionRequestBody.builder()
.reaction_type(lark_oapi.api.im.v1.CreateMessageReactionRequestBodyReactionType(emoji_type=reaction))
.build()
)
.build()
)
resp = await client.im.message.reaction.create(req)
if resp.success():
return DeliveryResult(success=True)
return DeliveryResult(success=False, error=resp.msg)
except AttributeError:
return DeliveryResult(
success=False,
error="SDK version incompatible: CreateMessageReactionRequestReactionType not found",
)
except Exception as e:
logger.error(f"Feishu send_reaction failed: {e}")
return DeliveryResult(success=False, error=str(e))
def _make_create_msg_request(
receive_id: str, chat_type: str, msg_type: str, content_json: str, *, silent: bool = False
) -> Any:
receive_id_type = _resolve_receive_id_type(chat_type)
body_builder = (
lark_oapi.api.im.v1.CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type(msg_type)
.content(content_json)
)
req_builder = (
lark_oapi.api.im.v1.CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(body_builder.build())
)
return req_builder.build()
def is_local_image_path(text: str) -> str | None:
cleaned = text.strip()
if os.path.isabs(cleaned) and os.path.splitext(cleaned)[1].lower() in IMAGE_EXTENSIONS:
if os.path.isfile(cleaned):
return cleaned
return None
async def update_card_message(client: Any, message_id: str, content: str) -> DeliveryResult:
if not HAS_LARK_SDK:
return DeliveryResult(success=False, error="lark-oapi SDK not available")
try:
content_json = json.dumps(
{
"config": {"wide_screen_mode": True},
"header": {"title": {"tag": "plain_text", "content": content[:100]}},
"elements": [{"tag": "markdown", "content": content}],
},
ensure_ascii=False,
)
request = (
lark_oapi.api.im.v1.PatchMessageRequest.builder()
.message_id(message_id)
.request_body(lark_oapi.api.im.v1.PatchMessageRequestBody.builder().content(content_json).build())
.build()
)
resp = await client.im.v1.message.patch(request)
if resp.success():
return DeliveryResult(success=True, message_id=message_id)
code = getattr(resp, "code", -1)
msg = getattr(resp, "msg", str(resp))
return DeliveryResult(success=False, error=f"Feishu API error: {msg}")
except Exception as e:
logger.error(f"Feishu update_card_message failed: {e}")
return DeliveryResult(success=False, error=str(e))