1. 移除多个文件中的空行、冗余导入
2. 修复文件末尾缺少换行符的问题
3. 新增并补全飞书多类工具API实现:
- 多维表格:更新、删除记录,列出视图
- 文档:更新、追加、删除块
- 云文档:上传、下载文件
- 群组:创建、添加成员、更新信息、创建公告
- 目录:重构用户部门缓存逻辑
4. 优化消息发送、回复、转发等API的错误处理和逻辑
5. 新增消息列表查询、已读状态查询等功能
498 lines
17 KiB
Python
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
|