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

498 lines
17 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
_LarkAccessDenied = lark_oapi.exception.AccessDeniedException
_LarkInvalidArgs = lark_oapi.exception.InvalidArgsException
except ImportError:
HAS_LARK_SDK = False
lark_oapi = None # type: ignore
class _DummyLarkError(BaseException):
pass
_LarkAccessDenied = _DummyLarkError
_LarkInvalidArgs = _DummyLarkError
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
CHUNK_STRATEGIES = ["auto", "zero", "newline", "sentence"]
REPLY_DEGRADE_CODES = {230011, 231003}
# Feishu 401 错误分类: 消息中包含这些关键词的才是 token 过期,需要刷新
_TOKEN_EXPIRED_KEYWORDS = ("tenant_access_token", "app_access_token", "token expire", "token invalid")
def _is_token_expired_error(code: int, msg: str) -> bool:
if code != 401:
return False
msg_lower = msg.lower()
return any(kw in msg_lower for kw in _TOKEN_EXPIRED_KEYWORDS)
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,
chat_type: str | None = None,
chat_id: str | None = None,
) -> 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,
chat_id if chat_id else message_id,
content,
chat_type=chat_type or "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 _LarkAccessDenied as e:
return DeliveryResult(success=False, error=f"Access denied: {e}")
except _LarkInvalidArgs as e:
return DeliveryResult(success=False, error=f"Invalid args: {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 _is_token_expired_error(code, msg):
return DeliveryResult(success=False, error=f"Auth failed: {msg}", error_code="auth_expired")
return DeliveryResult(success=False, error=f"Send failed: {msg}")
except AttributeError as e:
return DeliveryResult(success=False, error=f"SDK error: {e}")
except _LarkAccessDenied as e:
return DeliveryResult(success=False, error=f"Access denied: {e}")
except _LarkInvalidArgs as e:
return DeliveryResult(success=False, error=f"Invalid args: {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 list_messages(
client: Any,
chat_id: str,
*,
page_token: str = "",
page_size: int = 20,
start_time: str = "",
end_time: str = "",
) -> dict[str, Any]:
if not HAS_LARK_SDK:
return {"messages": [], "has_more": False, "page_token": ""}
try:
request_builder = (
lark_oapi.api.im.v1.ListMessageRequest.builder()
.container_id_type("chat")
.container_id(chat_id)
.page_size(min(page_size, 50))
)
if page_token:
request_builder.page_token(page_token)
if start_time:
request_builder.start_time(start_time)
if end_time:
request_builder.end_time(end_time)
request = request_builder.build()
resp = await client.im.v1.message.list(request)
if not resp.success():
raise RuntimeError(f"获取消息列表失败: {resp.msg}")
data = resp.data if hasattr(resp, "data") else {}
items = data.get("items", [])
messages = [
{
"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", []),
}
for item in items
]
return {
"messages": messages,
"has_more": data.get("has_more", False),
"page_token": data.get("page_token", ""),
}
except _LarkAccessDenied as e:
raise RuntimeError(f"权限不足: {e}") from e
except _LarkInvalidArgs as e:
raise RuntimeError(f"参数错误: {e}") from e
except Exception as e:
raise RuntimeError(f"获取消息列表失败: {e}") from e
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 _is_token_expired_error(code, msg):
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 _LarkAccessDenied as e:
return DeliveryResult(success=False, error=f"Access denied: {e}")
except _LarkInvalidArgs as e:
return DeliveryResult(success=False, error=f"Invalid args: {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 _LarkAccessDenied as e:
return DeliveryResult(success=False, error=f"Access denied: {e}")
except _LarkInvalidArgs as e:
return DeliveryResult(success=False, error=f"Invalid args: {e}")
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())
.silent(silent)
)
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)
getattr(resp, "code", -1)
msg = getattr(resp, "msg", str(resp))
return DeliveryResult(success=False, error=f"Feishu API error: {msg}")
except _LarkAccessDenied as e:
return DeliveryResult(success=False, error=f"Access denied: {e}")
except _LarkInvalidArgs as e:
return DeliveryResult(success=False, error=f"Invalid args: {e}")
except Exception as e:
logger.error(f"Feishu update_card_message failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def forward_message(
client: Any,
message_id: str,
target_chat_id: str,
*,
target_type: str = "chat",
) -> DeliveryResult:
if not HAS_LARK_SDK:
return DeliveryResult(success=False, error="lark-oapi SDK not available")
try:
request_body = lark_oapi.api.im.v1.ForwardMessageRequestBody.builder().receive_id(target_chat_id).build()
request = (
lark_oapi.api.im.v1.ForwardMessageRequest.builder()
.message_id(message_id)
.receive_id_type(target_type)
.request_body(request_body)
.build()
)
resp = await client.im.v1.message.forward(request)
if resp.success():
new_msg_id = resp.data.get("message_id", "") if hasattr(resp, "data") else ""
return DeliveryResult(success=True, message_id=new_msg_id)
code = getattr(resp, "code", -1)
msg = getattr(resp, "msg", str(resp))
if _is_token_expired_error(code, msg):
return DeliveryResult(success=False, error=f"Auth failed: {msg}", error_code="auth_expired")
return DeliveryResult(success=False, error=f"Feishu API error: {msg}")
except _LarkAccessDenied as e:
return DeliveryResult(success=False, error=f"Access denied: {e}")
except _LarkInvalidArgs as e:
return DeliveryResult(success=False, error=f"Invalid args: {e}")
except Exception as e:
logger.error(f"Feishu forward_message failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def get_message_read_status(
client: Any,
message_id: str,
*,
page_token: str = "",
page_size: int = 20,
) -> dict[str, Any]:
if not HAS_LARK_SDK:
return {"users": [], "has_more": False, "page_token": ""}
try:
request_builder = (
lark_oapi.api.im.v1.ListReadUserRequest.builder().message_id(message_id).page_size(min(page_size, 100))
)
if page_token:
request_builder.page_token(page_token)
request = request_builder.build()
resp = await client.im.v1.message.read_user.list(request)
if not resp.success():
raise RuntimeError(f"获取已读状态失败: {resp.msg}")
data = resp.data if hasattr(resp, "data") else {}
items = data.get("items", [])
users = [
{
"user_id": item.get("user_id", ""),
"timestamp": item.get("timestamp", ""),
}
for item in items
]
return {
"users": users,
"has_more": data.get("has_more", False),
"page_token": data.get("page_token", ""),
}
except _LarkAccessDenied as e:
raise RuntimeError(f"权限不足: {e}") from e
except _LarkInvalidArgs as e:
raise RuntimeError(f"参数错误: {e}") from e
except Exception as e:
raise RuntimeError(f"获取已读状态失败: {e}") from e