"""读一张已构建卡片的测试辅助 —— 卡片 schema 的形状只在这里假设一次。 2026-09-07 卡片从 v1(顶层 ``elements``)换到 2.0(``body.elements``),十来个 测试文件里散着的 ``card["elements"]`` 一起变红。它们断言的东西(落款去重、裸 链接去重、按钮在不在)一条都没变,变的只是取元素的路径 —— 所以那个路径收进 这里,下一次 schema 动的时候改一处。 顺带把「2.0 里已经不存在的元素」做成断言:``note`` 和 ``action`` 两个 tag 被 2.0 删掉了(飞书直接拒收整张卡),而它们在渲染器里各有一处替代实现。 :func:`assert_card_schema` 让任何一次回退都在测试里当场炸掉,而不是在生产上 变成「这个人没收到那张卡」。 """ from __future__ import annotations from typing import Any #: 2.0 删掉的元素 tag。渲染器再也不该产出它们。 FORBIDDEN_TAGS = ("note", "action") def elements_of(card: dict[str, Any]) -> list[dict[str, Any]]: """卡片正文的元素列表。""" return list((card.get("body") or {}).get("elements") or []) def markdown_of(card: dict[str, Any]) -> str: """正文的 markdown 块拼成一段,用来断言「这段文字在不在卡上」。 **不含**小灰字块。2.0 之前页脚是 ``note`` 元素,天然不在 markdown 里;现在 它也是 markdown(只是 notation 字号),如果一并算进来,「正文里不该再有落款」 这条断言就会把页脚自己的那一个落款当成违规。 """ return "\n".join( el["content"] for el in elements_of(card) if el.get("tag") == "markdown" and el.get("text_size") != "notation" ) def small_grey_of(card: dict[str, Any]) -> list[str]: """小灰字块(页脚、hint 分节)的可见文字。 2.0 之前这些是 ``note`` 元素的 plain_text;现在是 ``notation`` 字号的 markdown,灰色靠 ```` 包一层 —— 那层包装是渲染细节, 断言关心的是读者看到的字,所以在这里剥掉。 """ out: list[str] = [] for el in elements_of(card): if el.get("tag") != "markdown" or el.get("text_size") != "notation": continue content = str(el.get("content") or "") prefix, suffix = "", "" if content.startswith(prefix) and content.endswith(suffix): content = content[len(prefix):-len(suffix)] out.append(content) return out def buttons_of(card: dict[str, Any]) -> list[dict[str, Any]]: """卡上所有按钮 —— 顶层的那一个,和 ``column_set`` 每列里的那些。""" out: list[dict[str, Any]] = [] for el in elements_of(card): if el.get("tag") == "button": out.append(el) elif el.get("tag") == "column_set": for column in el.get("columns") or []: out.extend( child for child in (column.get("elements") or []) if child.get("tag") == "button" ) return out def button_urls(card: dict[str, Any]) -> list[str]: """每个按钮的目标地址。2.0 把它放在 ``behaviors`` 里,不再是裸 ``url``。""" urls: list[str] = [] for button in buttons_of(card): for behavior in button.get("behaviors") or []: if behavior.get("type") == "open_url": urls.append(str(behavior.get("default_url") or "")) return urls def callback_buttons(card: dict[str, Any]) -> list[dict[str, Any]]: """按钮里会「回调我们」而不是「打开网页」的那些。""" return [ button for button in buttons_of(card) if any( behavior.get("type") == "callback" for behavior in button.get("behaviors") or [] ) ] def callback_value(button: dict[str, Any]) -> dict[str, Any]: """一个回调按钮回传的 payload。 2.0 把它挪进了 ``behaviors[].value``;v1 放在按钮顶层的 ``value``。两种都 读,因为 ``card_actions._action_value`` 也两种都读 —— 换渲染器那天之前发出 去的卡片还会在聊天记录里躺几个月,那些卡上的按钮被点时必须照样能用。 """ for behavior in button.get("behaviors") or []: value = behavior.get("value") if behavior.get("type") == "callback" and isinstance(value, dict): return value legacy = button.get("value") return legacy if isinstance(legacy, dict) else {} def button_labels(card: dict[str, Any]) -> list[str]: return [ str((button.get("text") or {}).get("content") or "") for button in buttons_of(card) ] def header_badges(card: dict[str, Any]) -> list[str]: return [ str((tag.get("text") or {}).get("content") or "") for tag in ((card.get("header") or {}).get("text_tag_list") or []) ] def assert_card_schema(card: dict[str, Any]) -> None: """这张卡是一张合法的 2.0 卡,且没有用到 2.0 删掉的元素。""" assert card.get("schema") == "2.0", card.get("schema") assert "elements" not in card, "顶层 elements 是 v1 形状,2.0 放在 body 下" assert isinstance(card.get("body"), dict) for el in _walk(card["body"]): assert el.get("tag") not in FORBIDDEN_TAGS, ( f"{el.get('tag')} 在卡片 2.0 里已被删除,飞书会拒收整张卡" ) def _walk(node: Any) -> list[dict[str, Any]]: """深度遍历所有带 ``tag`` 的节点(列里、折叠面板里的也要看到)。""" found: list[dict[str, Any]] = [] if isinstance(node, dict): if "tag" in node: found.append(node) for value in node.values(): found.extend(_walk(value)) elif isinstance(node, list): for item in node: found.extend(_walk(item)) return found