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

295 lines
8.2 KiB
Python

from __future__ import annotations
from typing import Any
TEXT_CHUNK_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 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"] = {"tag": "plain_text", "content": placeholder}
if options:
sel["options"] = [
{"text": {"tag": "plain_text", "content": opt.get("text", "")}, "value": opt.get("value", "")}
for opt in 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"] = {"tag": "plain_text", "content": 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"] = {"tag": "plain_text", "content": 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"] = {"tag": "plain_text", "content": 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"] = {"tag": "plain_text", "content": 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"] = [
{"text": {"tag": "plain_text", "content": opt.get("text", "")}, "value": opt.get("value", "")}
for opt in options
]
if value:
sel["value"] = value
return sel
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 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,
) -> dict[str, Any]:
elements: list[dict[str, Any]] = []
if context_text:
elements.append(
{
"tag": "note",
"elements": [{"tag": "plain_text", "content": context_text}],
}
)
if dividers > 0:
for _ in range(dividers):
elements.append({"tag": "hr"})
if images:
for img_key in images:
elements.append(
{
"tag": "img",
"img_key": img_key,
"alt": {"tag": "plain_text", "content": ""},
}
)
elements.append({"tag": "markdown", "content": 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"] = {"tag": "plain_text", "content": str(sel["placeholder"])}
if "options" in sel:
selector_element["options"] = [
{"text": {"tag": "plain_text", "content": opt.get("text", "")}, "value": opt.get("value", "")}
for opt in sel["options"]
]
if "value" in sel:
selector_element["value"] = sel["value"]
if "initial_option" in sel:
selector_element["initial_option"] = sel["initial_option"]
if "initial_date" in sel:
selector_element["initial_date"] = sel["initial_date"]
if "initial_time" in sel:
selector_element["initial_time"] = sel["initial_time"]
if "initial_datetime" in sel:
selector_element["initial_datetime"] = sel["initial_datetime"]
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": {"tag": "plain_text", "content": 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": [{"tag": "plain_text", "content": note}],
}
)
header_title = f"{title} (回复中...)" if streaming else title
color = resolve_template_color(template, tone)
return {
"header": {
"title": {"tag": "plain_text", "content": header_title},
"template": color,
},
"elements": elements,
}