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

328 lines
8.7 KiB
Python

from __future__ import annotations
from typing import Any
TEXT_CHUNK_LIMIT = 30000
CARD_CONTENT_LIMIT = 30000
CARD_TEMPLATE_COLORS = {
"blue",
"green",
"red",
"orange",
"purple",
"indigo",
"wathet",
"turquoise",
"yellow",
"grey",
"carmine",
"violet",
"lime",
}
PRESENTATION_TONE_MAP = {
"default": "blue",
"danger": "red",
"warning": "orange",
"success": "green",
}
BUTTON_TYPE_MAP = {
"primary": "primary",
"danger": "danger",
"default": "default",
"": "default",
}
SELECTOR_TAGS = {
"select_static",
"select_person",
"date_picker",
"time_picker",
"datetime_picker",
"overflow",
}
def resolve_template_color(template: str | None = None, tone: str | None = None) -> str:
if template and template in CARD_TEMPLATE_COLORS:
return template
if tone and tone in PRESENTATION_TONE_MAP:
return PRESENTATION_TONE_MAP[tone]
return "blue"
def resolve_button_type(style: str | None = None) -> str:
if not style:
return "default"
return BUTTON_TYPE_MAP.get(style, "default")
def resolve_selector_tag(tag: str) -> str:
return tag if tag in SELECTOR_TAGS else "select_static"
def _build_plain_text(content: str) -> dict[str, Any]:
return {"tag": "plain_text", "content": content}
def _build_options(options: list[dict[str, str]]) -> list[dict[str, Any]]:
return [{"text": _build_plain_text(opt.get("text", "")), "value": opt.get("value", "")} for opt in options]
def make_select_static(
placeholder: str = "",
options: list[dict[str, str]] | None = None,
value: str = "",
initial_option: str = "",
) -> dict[str, Any]:
sel: dict[str, Any] = {"tag": "select_static"}
if placeholder:
sel["placeholder"] = _build_plain_text(placeholder)
if options:
sel["options"] = _build_options(options)
if value:
sel["value"] = value
if initial_option:
sel["initial_option"] = initial_option
return sel
def make_select_person(
placeholder: str = "",
value: str = "",
) -> dict[str, Any]:
sel: dict[str, Any] = {"tag": "select_person"}
if placeholder:
sel["placeholder"] = _build_plain_text(placeholder)
if value:
sel["value"] = value
return sel
def make_date_picker(
placeholder: str = "",
value: str = "",
initial_date: str = "",
) -> dict[str, Any]:
sel: dict[str, Any] = {"tag": "date_picker"}
if placeholder:
sel["placeholder"] = _build_plain_text(placeholder)
if value:
sel["value"] = value
if initial_date:
sel["initial_date"] = initial_date
return sel
def make_time_picker(
placeholder: str = "",
value: str = "",
initial_time: str = "",
) -> dict[str, Any]:
sel: dict[str, Any] = {"tag": "time_picker"}
if placeholder:
sel["placeholder"] = _build_plain_text(placeholder)
if value:
sel["value"] = value
if initial_time:
sel["initial_time"] = initial_time
return sel
def make_datetime_picker(
placeholder: str = "",
value: str = "",
initial_datetime: str = "",
) -> dict[str, Any]:
sel: dict[str, Any] = {"tag": "datetime_picker"}
if placeholder:
sel["placeholder"] = _build_plain_text(placeholder)
if value:
sel["value"] = value
if initial_datetime:
sel["initial_datetime"] = initial_datetime
return sel
def make_overflow_menu(
options: list[dict[str, str]] | None = None,
value: str = "",
) -> dict[str, Any]:
sel: dict[str, Any] = {"tag": "overflow"}
if options:
sel["options"] = _build_options(options)
if value:
sel["value"] = value
return sel
def make_input_field(
placeholder: str = "",
value: str = "",
multiline: bool = False,
max_length: int = 0,
) -> dict[str, Any]:
field: dict[str, Any] = {"tag": "input"}
if placeholder:
field["placeholder"] = _build_plain_text(placeholder)
if value:
field["value"] = value
if multiline:
field["multiline"] = True
if max_length:
field["max_length"] = max_length
return field
def make_checkbox_group(
options: list[dict[str, str]] | None = None,
values: list[str] | None = None,
) -> dict[str, Any]:
group: dict[str, Any] = {"tag": "checkbox"}
if options:
group["options"] = _build_options(options)
if values:
group["value"] = values
return group
def make_image_element(img_key: str, alt_text: str = "") -> dict[str, Any]:
return {
"tag": "img",
"img_key": img_key,
"alt": _build_plain_text(alt_text),
}
def build_feishu_text_content(content: str) -> dict[str, Any]:
return {"text": content}
def build_feishu_post_content(content: str) -> dict[str, Any]:
md_lines = content.split("\n")
paragraphs: list[list[dict[str, Any]]] = []
for line in md_lines:
if not line.strip():
paragraphs.append([{"tag": "text", "text": ""}])
else:
paragraphs.append([{"tag": "md", "text": line}])
return {"zh_cn": {"content": paragraphs}}
def is_post_format_requested(metadata: dict | None = None) -> bool:
if not metadata:
return False
return metadata.get("use_post_format", False) or metadata.get("usePostFormat", False)
def _truncate_card_content(content: str, limit: int = CARD_CONTENT_LIMIT) -> str:
if len(content) <= limit:
return content
suffix = "\n\n...(内容过长已截断)"
return content[: limit - len(suffix)] + suffix
def build_feishu_card(
content: str,
*,
title: str = "AI 助手",
buttons: list[dict[str, str]] | None = None,
streaming: bool = False,
url_unfurl: list[str] | None = None,
images: list[str] | None = None,
files: list[dict[str, str]] | None = None,
note: str | None = None,
template: str | None = None,
tone: str | None = None,
context_text: str | None = None,
dividers: int = 0,
selectors: list[dict[str, Any]] | None = None,
visible_to_operator: bool = False,
) -> dict[str, Any]:
elements: list[dict[str, Any]] = []
if context_text:
elements.append(
{
"tag": "note",
"elements": [_build_plain_text(context_text)],
}
)
for _ in range(dividers):
elements.append({"tag": "hr"})
if images:
for img_key in images:
elements.append(make_image_element(img_key))
truncated_content = _truncate_card_content(content)
elements.append({"tag": "markdown", "content": truncated_content})
if files:
for f in files:
name = f.get("name", "文件")
url = f.get("url", "")
link = f"[📎 {name}]({url})" if url else f"📎 {name}"
elements.append({"tag": "markdown", "content": link})
if selectors:
for sel in selectors:
selector_tag = resolve_selector_tag(sel.get("tag", "select_static"))
selector_element: dict[str, Any] = {"tag": selector_tag}
if "placeholder" in sel:
if isinstance(sel["placeholder"], dict):
selector_element["placeholder"] = sel["placeholder"]
else:
selector_element["placeholder"] = _build_plain_text(str(sel["placeholder"]))
if "options" in sel:
selector_element["options"] = _build_options(sel["options"])
for key in ("value", "initial_option", "initial_date", "initial_time", "initial_datetime"):
if key in sel:
selector_element[key] = sel[key]
elements.append(selector_element)
if buttons:
actions: list[dict[str, Any]] = []
for btn in buttons:
btn_type = resolve_button_type(btn.get("style"))
actions.append(
{
"tag": "button",
"text": _build_plain_text(btn.get("text", "")),
"type": btn_type,
"value": {"action": btn.get("action", "")},
}
)
elements.append({"tag": "action", "actions": actions})
if url_unfurl:
for url in url_unfurl:
elements.append({"tag": "markdown", "content": f"[🔗 {url}]({url})"})
if note:
elements.append(
{
"tag": "note",
"elements": [_build_plain_text(note)],
}
)
header_title = f"{title} (回复中...)" if streaming else title
color = resolve_template_color(template, tone)
card: dict[str, Any] = {
"header": {
"title": _build_plain_text(header_title),
"template": color,
},
"elements": elements,
}
if visible_to_operator:
card["config"] = {"update_multi": False}
return card