ForcePilot/backend/package/yuxi/channels/plugins/feishu/card_builder.py

208 lines
7.0 KiB
Python
Raw Normal View History

"""飞书 Card Kit 卡片构建模块。
将契约层 ``RichMessage`` DTO 映射为飞书 Card Kit Schema 2.0 JSON
并提供 Markdown 卡片流式卡片与降级 Markdown 文本的构建能力
仅依赖契约层 DTO 与标准库
"""
from __future__ import annotations
from typing import Any
from yuxi.channels.contract.dtos.outbound import (
RichMessage,
RichMessageButton,
RichMessageCheckbox,
RichMessageDatePicker,
RichMessageInput,
RichMessageSelect,
)
def build_card_from_rich_message(rich_message: RichMessage) -> dict[str, Any]:
"""将 ``RichMessage`` 映射为飞书 Card Kit Schema 2.0 卡片。
``title`` header.titleplain_text``text`` body markdown
``description`` body note``image_url`` body img``buttons``
column_set``selects`` / ``date_pickers`` / ``checkboxes`` / ``inputs``
对应交互元素均追加至 body.elements
"""
elements: list[dict[str, Any]] = []
if rich_message.text:
elements.append({"tag": "markdown", "content": rich_message.text})
if rich_message.description:
elements.append(
{
"tag": "note",
"elements": [{"tag": "plain_text", "content": rich_message.description}],
}
)
if rich_message.image_url:
elements.append(_image_element(rich_message.image_url))
if rich_message.buttons:
elements.append(_button_column_set(rich_message.buttons))
for sel in rich_message.selects:
elements.append(_select_element(sel))
for dp in rich_message.date_pickers:
elements.append(_date_picker_element(dp))
for cb in rich_message.checkboxes:
elements.append(_checkbox_element(cb))
for inp in rich_message.inputs:
elements.append(_input_element(inp))
card: dict[str, Any] = {"schema": "2.0"}
if rich_message.title:
card["header"] = {"title": {"tag": "plain_text", "content": rich_message.title}}
card["body"] = {"elements": elements}
return card
def build_markdown_card(text: str, title: str | None = None) -> dict[str, Any]:
"""构建 Markdown 卡片title 为空时不创建 header。"""
card: dict[str, Any] = {"schema": "2.0"}
if title:
card["header"] = {"title": {"tag": "plain_text", "content": title}}
card["body"] = {"elements": [{"tag": "markdown", "content": text}]}
return card
def build_streaming_card(content: str, sequence: int, is_final: bool) -> dict[str, Any]:
"""构建流式更新卡片。
``content`` 为流式文本``is_final=True`` 时追加已完成标识
``sequence`` 为流式序号由调用方用于协议侧顺序追踪不嵌入卡片体
"""
elements: list[dict[str, Any]] = [{"tag": "markdown", "content": content}]
if is_final:
elements.append(
{
"tag": "note",
"elements": [{"tag": "plain_text", "content": "已完成"}],
}
)
return {"schema": "2.0", "body": {"elements": elements}}
def degrade_to_markdown(rich_message: RichMessage) -> str:
"""将 ``RichMessage`` 降级为 Markdown 文本,空字段跳过。
标题用 ``# `` 前缀,按钮用 ``- [label](value)`` 列表,
输入框用 ``- {label}: {placeholder}`` 列表
"""
lines: list[str] = []
if rich_message.title:
lines.append(f"# {rich_message.title}")
if rich_message.text:
lines.append(rich_message.text)
if rich_message.description:
lines.append(rich_message.description)
if rich_message.buttons:
for btn in rich_message.buttons:
target = btn.value or btn.action
lines.append(f"- [{btn.label}]({target})")
if rich_message.inputs:
for inp in rich_message.inputs:
if inp.placeholder:
lines.append(f"- {inp.label}: {inp.placeholder}")
else:
lines.append(f"- {inp.label}")
return "\n".join(lines)
def _image_element(image_url: str) -> dict[str, Any]:
if image_url.startswith(("http://", "https://")):
return {"tag": "img", "url": image_url}
return {"tag": "img", "img_key": image_url}
def _button_column_set(buttons: tuple[RichMessageButton, ...]) -> dict[str, Any]:
columns: list[dict[str, Any]] = []
for btn in buttons:
value: dict[str, Any] = {"action": btn.action}
if btn.value is not None:
value["value"] = btn.value
columns.append(
{
"tag": "column",
"width": "weighted",
"weight": 1,
"elements": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": btn.label},
"value": value,
"type": "default",
}
],
}
)
return {"tag": "column_set", "columns": columns}
def _select_element(sel: RichMessageSelect) -> dict[str, Any]:
el: dict[str, Any] = {
"tag": "select_static",
"name": sel.name,
"label": {"tag": "plain_text", "content": sel.label},
"placeholder": {"tag": "plain_text", "content": sel.placeholder or sel.label},
"options": [
{
"text": {"tag": "plain_text", "content": opt.label},
"value": opt.value,
}
for opt in sel.options
],
}
if sel.default_value is not None:
el["value"] = sel.default_value
return el
def _date_picker_element(dp: RichMessageDatePicker) -> dict[str, Any]:
el: dict[str, Any] = {
"tag": "date_picker",
"name": dp.name,
"label": {"tag": "plain_text", "content": dp.label},
"placeholder": {"tag": "plain_text", "content": dp.placeholder or dp.label},
}
if dp.default_value is not None:
el["value"] = dp.default_value
if dp.min_date is not None:
el["min"] = dp.min_date
if dp.max_date is not None:
el["max"] = dp.max_date
return el
def _checkbox_element(cb: RichMessageCheckbox) -> dict[str, Any]:
el: dict[str, Any] = {
"tag": "checkbox",
"name": cb.name,
"label": {"tag": "plain_text", "content": cb.label},
"options": [
{
"text": {"tag": "plain_text", "content": opt.label},
"value": opt.value,
}
for opt in cb.options
],
}
if cb.default_values:
el["value"] = list(cb.default_values)
return el
def _input_element(inp: RichMessageInput) -> dict[str, Any]:
el: dict[str, Any] = {
"tag": "input",
"name": inp.name,
"label": {"tag": "plain_text", "content": inp.label},
"placeholder": {"tag": "plain_text", "content": inp.placeholder or inp.label},
"input_type": inp.input_type,
}
if inp.default_value is not None:
el["value"] = inp.default_value
if inp.max_length is not None:
el["max_length"] = inp.max_length
return el