"""「使用帮助」那张卡 —— 助手在飞书里的门面。 ## 它替换了什么 在这之前,机器人菜单里的「使用帮助」和 `/help` 回的是**一整屏纯文本**: `msg_type="text"` 渲染不了 markdown,所以层级只能靠【】和「•」表达,读起来是 一堵字墙,而且读完还是得自己想「那我现在打什么」。真实反馈就是这句: 「有更好看的东西啊 不是这样一堆文字」。 现在它是一张卡片 2.0:标题栏、三行能力说明、八类问题的按钮阵,以及一颗直达 人工的按钮。点一类进二级卡,再点一句**就等于把那句话打给助手** —— :mod:`app.assistant.starters` 是那些句子的唯一出处。 ## 为什么按钮回传 key 而不是句子 按钮载荷里只有 ``{"c": <类>, "s": <条>}``。句子由服务端按**当事人的语言**从注册表 里取,于是:载荷小、老卡片改文案即生效、而伪造一个 payload 也只能选到表里已有的 那几句 —— 注入不进任意文本,更进不了模型的输入。 ## 版式约束(踩过的坑,别回退) * **schema 2.0**,和平台其它卡片同一套(``card_design``)。混 v1 元素会被校验器 整张拒掉。 * **``note`` 元素在 2.0 已删**;小灰字一律 ``markdown`` + ``text_size:"notation"``。 * **多按钮必须放 ``column_set``**(2.0 删掉了 ``action`` 容器)。分类阵用 ``flex_mode:"bisect"`` + 按钮 ``width:"fill"``:每颗按钮铺满自己那半列, 于是桌面端不会在两颗按钮之间留一大片空(#3534 那次的抱怨),手机端也不会 出现「三颗挤一行、第四颗自己掉下去」。 * **一行两颗**是分类阵的上限。问题句子比分类名长,所以二级卡上一行只放一颗、 整行铺满。 * 头部**不挂图标**:品牌身份由聊天里的机器人头像承担,而语义图标要留给 「已批准 / 告警」那类真的有状态的卡(见 ``card_design`` 的说明)。 """ from __future__ import annotations from typing import Any from app.assistant import starters from app.core.locale import DEFAULT_LANG, Lang from app.notifications import card_design #: 按钮回传的动作名。``card_actions`` 认领这四个,改名等于让聊天记录里的老卡片 #: 变成「点了没反应」—— 要改就得同时接受旧名。 ACTION_HOME = "assistant.help.home" ACTION_CATEGORY = "assistant.help.category" ACTION_ASK = "assistant.help.ask" ACTION_HUMAN = "assistant.help.human" ACTION_LANG = "assistant.help.lang" #: 载荷里的键。短是因为飞书对 ``value`` 的体积有限制,而这张卡上有 32 颗按钮。 KEY_CATEGORY = "c" KEY_STARTER = "s" KEY_LANG = "l" _PORTAL = "https://helix.pingcap.net" _TITLE: dict[Lang, str] = { "zh": "Helix IT 助手", "en": "Helix IT assistant", "ja": "Helix IT アシスタント", } _SUBTITLE: dict[Lang, str] = { "zh": "用大白话说就行,不用记指令", "en": "Just say it in plain language — no commands to memorise", "ja": "普段の言葉でどうぞ。コマンドは不要です", } _CAN_LOOK_UP: dict[Lang, str] = { "zh": "**能查** 设备、软件与账号权限、网络与 VPN、IT 制度与流程," "以及你自己提过的申请和工单进度。", "en": "**I can look up** your devices, software and account access, network " "and VPN, IT policy, and the status of requests and tickets you filed.", "ja": "**調べられること** 端末、ソフトウェアとアカウント権限、ネットワークと " "VPN、IT 制度と手順、そしてあなたの申請・チケットの進捗。", } _CAN_FILE: dict[Lang, str] = { "zh": "**能办** 平台上已有的申请我都能替你发起 —— 先把要提交的内容做成一张" "卡片给你确认,你点了才真的提交。", "en": "**I can file** any request the platform already supports — you get a " "card showing exactly what will be submitted, and nothing is sent until " "you confirm it.", "ja": "**手続きできること** プラットフォームにある申請を代わりに起票します。" "送信内容をカードでお見せし、確認いただくまで送信しません。", } _CANNOT: dict[Lang, str] = { "zh": "**办不了** 查不到、你没权限、或者这件事必须人来做 —— 我会说明原因" "并帮你转人工,不会编一个答案。", "en": "**When I can't** if I cannot find it, you lack access, or it needs a " "person, I say why and hand it to IT. I will not invent an answer.", "ja": "**できないとき** 見つからない、権限がない、人の対応が必要 —— " "理由をお伝えし、担当者へ引き継ぎます。憶測ではお答えしません。", } #: 平台一个月往这个会话里推约 3,800 张通知卡,而**回复其中任何一张就能接着问** #: 这件事,以前只写在纯文本说明里(卡片页脚里那句在 2026-08-31 被摘掉了,理由是 #: 每张卡都重复一遍就是噪音)。现在这张卡是那份说明的主面,所以这句话必须在这里 #: —— 否则它就成了一个没有任何地方讲过的能力。 _REPLY_TO_CARDS: dict[Lang, str] = { "zh": "**接着聊** IT 推给你的任何一张卡片,直接回复它就能问 —— " "我知道你说的是哪一张,不用再复述一遍。", "en": "**Keep the thread** reply to any card IT pushes you and just ask — " "I know which one you mean, so you don't have to describe it again.", "ja": "**続けて聞く** IT から届いたカードにそのまま返信してください。" "どのカードのことか分かるので、説明し直す必要はありません。", } _PICK_A_TOPIC: dict[Lang, str] = { "zh": "选一类,或者直接把你遇到的事打给我。", "en": "Pick a topic, or just tell me what's going on.", "ja": "カテゴリを選ぶか、困っていることをそのままお書きください。", } #: 和答案卡上那颗**同名**(``answer_card._HANDOFF``)。两张卡的机制不同 —— #: 那颗把答案连同上下文交给一个人并拉群,这颗从零开始先问一句再起工单 —— #: 但员工按下去要的是同一件事:找个人。同一件事在两张卡上叫两个名字,才是那种 #: 「这两个按钮有什么区别」的问题的来源。 _TALK_TO_HUMAN: dict[Lang, str] = { "zh": "转人工", "en": "Ask a human", "ja": "担当者に相談", } _BACK: dict[Lang, str] = { "zh": "‹ 返回", "en": "‹ Back", "ja": "‹ 戻る", } _HOME_FOOTER: dict[Lang, str] = { "zh": f"/new 开新话题 · /help 再看一次 · 设备与申请也可以到 {_PORTAL} 自己查", "en": f"/new for a fresh topic · /help to see this again · your devices and " f"requests are also at {_PORTAL}", "ja": f"/new で新しい話題 · /help で再表示 · 端末と申請は {_PORTAL} でも確認できます", } _CATEGORY_SUBTITLE: dict[Lang, str] = { "zh": "点一句直接问,也可以自己描述", "en": "Tap one, or describe it in your own words", "ja": "タップするか、ご自身の言葉でどうぞ", } _CATEGORY_FOOTER: dict[Lang, str] = { "zh": "没有想问的?直接把你遇到的事打给我就行。", "en": "Nothing here? Just type what's going on.", "ja": "該当がなければ、ご相談内容をそのままお書きください。", } _SENT: dict[Lang, str] = { "zh": "已经发出去了:「{question}」 答案马上到。", "en": "Sent: “{question}” the answer is on its way.", "ja": "送信しました:「{question}」 まもなく回答します。", } _ASK_TOAST: dict[Lang, str] = { "zh": "已经发出去了,我这就查", "en": "Sent — looking into it now", "ja": "送信しました。確認します", } _GONE_TOAST: dict[Lang, str] = { "zh": "这一项已经不在了,请直接把问题打给我", "en": "That item is gone — just type your question instead", "ja": "この項目は現在ありません。質問を直接お書きください", } #: 语言自助那一行。**放在帮助卡上**是因为这里正是它被需要的时刻:一个把语言 #: 设错(或者从来没设过、被按国家推断成中文)的人,第一次意识到「这东西说的 #: 不是我的语言」就是打开这张卡的时候 —— 而在此之前,改语言这件事只存在于网页 #: 个人设置页的一个下拉框里,他得先知道有那个页面。 #: #: 三个名字都用**目标语言自己**写(中文 / English / 日本語),不翻译 —— 一个只 #: 读英文的人在中文卡上要认出「英语」这两个字,比认出「English」难。 #: 顺序固定。写成一个带类型的常量而不是行内元组 —— 行内的 ``("zh","en","ja")`` #: 在类型检查眼里是 ``tuple[str, ...]``,拿去索引 ``dict[Lang, str]`` 会报错, #: 而那个报错的修法要么是这里、要么是一处 ``cast``,前者顺带也让顺序有了名字。 _LANGUAGE_ORDER: tuple[Lang, ...] = ("zh", "en", "ja") _LANGUAGE_NAMES: dict[Lang, str] = { "zh": "中文", "en": "English", "ja": "日本語", } #: 当前选中的那一个前面加个勾。没显式选过时(按国家推断出来的)**一个都不勾** #: —— 把推断值画成选中态,会让「我明明没选过」变成一个没人能回答的问题。 _LANG_CHECK = "✓ " _LANGUAGE_HINT: dict[Lang, str] = { "zh": "回答用的语言 · 也会改你收到的通知", "en": "Language for answers · also changes the notifications you get", "ja": "回答の言語 · 受け取る通知にも反映されます", } _LANG_DONE: dict[Lang, str] = { "zh": "以后都用中文跟你说,通知也一样", "en": "I'll speak English from now on — notifications too", "ja": "これからは日本語でお話しします。通知も同様です", } def language_switched_toast(lang: Lang) -> dict[str, Any]: return {"type": "success", "content": _pick(_LANG_DONE, lang)} def _pick(table: dict[Lang, str], lang: Lang) -> str: return table.get(lang) or table[DEFAULT_LANG] def _escape(text: str) -> str: """飞书 markdown 会解析内联 HTML,所以尖括号必须转义。 这些文案是我们自己写的,但**分类名和问题句子会被拼进 markdown**,而它们 来自一张会被继续编辑的表 —— 让转义成为渲染路径的一部分,比依赖「以后没人 会往里写尖括号」可靠。 """ return text.replace("<", "<").replace(">", ">") def _markdown(content: str) -> dict[str, Any]: return {"tag": "markdown", "content": content} def _small_grey(text: str) -> dict[str, Any]: """小灰字。2.0 没有 ``note`` 元素,只能靠 notation 字号的 markdown。""" return { "tag": "markdown", "text_size": card_design.FOOTER_TEXT_SIZE, "content": f"{_escape(text)}", } def _button(label: str, value: dict[str, Any], *, style: str = "default") -> dict[str, Any]: """一颗回传给我们的按钮,铺满它所在的那一列。 载荷只带标识符,不带凭据 —— 点击者的身份来自飞书签名的回调信封,所以一个 被复制走的 value 什么也换不到。 """ return { "tag": "button", "text": {"tag": "plain_text", "content": label}, "type": style, "width": "fill", "behaviors": [{"type": "callback", "value": value}], } def _two_up(buttons: list[dict[str, Any]]) -> list[dict[str, Any]]: """把按钮两颗一行排进 ``column_set``。 等分(``bisect``)+ 按钮 ``width:"fill"`` 是这里唯一对的组合:每颗按钮铺满 自己那半列,桌面端不留中缝、手机端不折行。奇数个时最后一行只有一颗,仍然 占满半宽 —— 让它通栏会看起来像另一种操作。 """ rows: list[dict[str, Any]] = [] for i in range(0, len(buttons), 2): pair = buttons[i:i + 2] rows.append({ "tag": "column_set", "flex_mode": "bisect", "horizontal_spacing": card_design.COLUMN_GAP, "columns": [ {"tag": "column", "width": "weighted", "weight": 1, "elements": [b]} for b in pair ], }) return rows def _card(*, title: str, subtitle: str, elements: list[dict[str, Any]]) -> dict[str, Any]: header: dict[str, Any] = { "title": {"tag": "plain_text", "content": title}, "template": card_design.tone_for(card_design.STATUS_INFO).color, } if subtitle: header["subtitle"] = {"tag": "plain_text", "content": subtitle} return { "schema": card_design.SCHEMA_VERSION, "config": {"update_multi": True, "width_mode": "fill"}, "header": header, "body": {"elements": elements}, } def build_home_card( lang: Lang = DEFAULT_LANG, *, chosen: Lang | None = None, ) -> dict[str, Any]: """一级卡:助手能干什么 + 八类问题的入口 + 一颗转人工 + 语言自助。 ``chosen`` 是这个人**显式设过**的语言(没设过就是 ``None``),只用来决定 语言那一行里哪一个前面打勾 —— 它和 ``lang`` 不是一回事:``lang`` 是这张卡 用什么语言画(没设过时按国家推断),而没设过的人不该看到任何一个被勾上。 """ elements: list[dict[str, Any]] = [ _markdown("\n".join(( _escape(_pick(_CAN_LOOK_UP, lang)), _escape(_pick(_CAN_FILE, lang)), _escape(_pick(_REPLY_TO_CARDS, lang)), _escape(_pick(_CANNOT, lang)), ))), {"tag": "hr"}, _markdown(_escape(_pick(_PICK_A_TOPIC, lang))), ] elements.extend(_two_up([ _button( category.title(lang), {"action": ACTION_CATEGORY, KEY_CATEGORY: category.key}, ) for category in starters.CATEGORIES ])) elements.append({"tag": "hr"}) elements.append(_button(_pick(_TALK_TO_HUMAN, lang), {"action": ACTION_HUMAN})) elements.append(_small_grey(_pick(_LANGUAGE_HINT, lang))) elements.append({ "tag": "column_set", "flex_mode": "flow", "horizontal_spacing": "12px", "columns": [ { "tag": "column", "width": "auto", "elements": [{ "tag": "button", "text": { "tag": "plain_text", "content": ( f"{_LANG_CHECK}{_LANGUAGE_NAMES[code]}" if code == chosen else _LANGUAGE_NAMES[code] ), }, "type": "text", "size": "small", "behaviors": [{ "type": "callback", "value": {"action": ACTION_LANG, KEY_LANG: code}, }], }], } for code in _LANGUAGE_ORDER ], }) elements.append(_small_grey(_pick(_HOME_FOOTER, lang))) return _card( title=_pick(_TITLE, lang), subtitle=_pick(_SUBTITLE, lang), elements=elements, ) def build_category_card( category: starters.StarterCategory, lang: Lang = DEFAULT_LANG, *, sent: str | None = None, ) -> dict[str, Any]: """二级卡:这一类管到哪 + 几句可以直接点的问题。 ``sent`` 是刚刚被点掉的那句话。它以灰字留在按钮下方,让「我按了,然后呢」 有一个当场的回音 —— 答案是另一条消息,而在它到达之前,这张卡是员工唯一 看得见的反馈。 """ elements: list[dict[str, Any]] = [_small_grey(category.summary(lang))] elements.extend( _button( item.question(lang), { "action": ACTION_ASK, KEY_CATEGORY: category.key, KEY_STARTER: item.key, }, ) for item in category.starters ) if sent: elements.append(_small_grey(_pick(_SENT, lang).format(question=sent))) elements.append({"tag": "hr"}) elements.extend(_two_up([ _button(_pick(_BACK, lang), {"action": ACTION_HOME}, style="text"), _button(_pick(_TALK_TO_HUMAN, lang), {"action": ACTION_HUMAN}), ])) elements.append(_small_grey(_pick(_CATEGORY_FOOTER, lang))) return _card( title=category.title(lang), subtitle=_pick(_CATEGORY_SUBTITLE, lang), elements=elements, ) def ask_toast(lang: Lang = DEFAULT_LANG) -> dict[str, Any]: return {"type": "info", "content": _pick(_ASK_TOAST, lang)} def gone_toast(lang: Lang = DEFAULT_LANG) -> dict[str, Any]: """按钮指向一条已经不在表里的开场白 —— 改名或删条之后的老卡片。""" return {"type": "warning", "content": _pick(_GONE_TOAST, lang)} def raw(card: dict[str, Any]) -> dict[str, Any]: """包成飞书回调要的「用这张卡替换原卡」信封。""" return {"type": "raw", "data": card} __all__ = [ "ACTION_ASK", "ACTION_CATEGORY", "ACTION_HOME", "ACTION_HUMAN", "KEY_CATEGORY", "KEY_STARTER", "ACTION_LANG", "KEY_LANG", "ask_toast", "build_category_card", "build_home_card", "gone_toast", "language_switched_toast", "raw", ]