新增飞书机器人适配器全套功能,包括: - 基础适配器入口与工具导出 - 消息格式化、卡片渲染、回复调度逻辑 - 会话ID生成、模型覆盖策略 - 消息发送缓存、顺序队列管理 - 飞书签名验证、加解密webhook请求 - 审批权限校验、机器人菜单事件处理 - 文档评论、钉消息、语音转码处理 - 静态/动态目录管理、子代理生命周期管理 - 各类工具集:聊天、云盘、文档、知识库API封装
310 lines
10 KiB
Python
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))
|