"""飞书 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