本次提交包含多项代码优化与规范修正: 1. 文档与注释优化:修正注释术语、补充注解与FR编号 2. 代码格式调整:统一空格、换行与缩进规范 3. 类型与接口完善:补充__all__导出、修正返回类型注解 4. 错误处理增强:新增领域错误类与校验逻辑 5. 依赖与导入调整:修复路径引用、统一时区导入 6. 协议与契约更新:完善接口文档与一致性注解
208 lines
7.0 KiB
Python
208 lines
7.0 KiB
Python
"""飞书 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.title(plain_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
|