"""群里那张工单卡 —— 这个房间在讲的那件事,现在是什么状态。 ## 为什么群里必须有一张卡 一个「转人工」群里原本只有三样东西:一句开场白、一条转发过来的回答,和之后的 聊天记录。**工单号在聊天记录里滚上去就再也找不到**,状态更是完全不可见 —— 群里 的人要知道「这单现在算认领了吗」,只能去问,或者去翻另一个界面。 所以群里挂一张卡,它就是这张工单本身:编号、状态、处理人、SLA,外加几颗一按就 生效的按钮。工单在任何地方变了状态(后台、门户、MCP、群里),这张卡**原地重画** —— 靠的是 `PATCH /im/v1/messages/{id}`,同一条消息对所有人同时更新。 ## 卡片是共享的,按钮不是 群里所有人都看得见这张卡,也都按得动它上面的按钮。所以**每个按钮的处理函数都 自己重新判断按的人是谁**,而不是拿「这张卡摆在他面前」当授权。这条在 `card_actions` 里执行;这里只负责画。 ## 设计语言复用平台的那一套 走 `notifications.service._build_interactive_card`,和确认卡、审批卡同一个渲染器 —— 状态决定头色/徽章/图标,事实行代替散文。助手的卡片不该看起来像另一个产品。 """ from __future__ import annotations import uuid from typing import Any from app.core.locale import DEFAULT_LANG, Lang from app.notifications import card_design from app.notifications.service import CardAction, CardFact, _build_interactive_card #: 按钮回传的动作名。与 ``card_actions._OWNED_ACTIONS`` 共用一份 —— 那里认领 #: 事件,这里画按钮,两处对不上的后果是「按钮存在但点了没反应」。 ACTION_CLAIM = "assistant.ticket.claim" ACTION_PRIORITY_UP = "assistant.ticket.priority_up" ACTION_REFRESH = "assistant.ticket.refresh" #: 处理人说「这单我解决了」。它**不直接结案** —— 先在群里提一段结案说明, #: 因为那段字要进台账,而一段没人读过就写上去的说明会被下一个人当成事实。 ACTION_RESOLVE = "assistant.ticket.resolve" #: 结案说明的确认。``value`` 里带着那段字的哈希,防的是「你确认的和写进去的 #: 不是同一段」——和助手确认卡同一条规矩。 ACTION_RESOLVE_CONFIRM = "assistant.ticket.resolve_confirm" #: 求助人说「我这边好了」。 ACTION_CLOSE = "assistant.ticket.close" #: 「还有问题」。已解决/已关闭的单子重开;还没解决的单子只留一句话。 ACTION_REOPEN = "assistant.ticket.reopen" #: 三档满意度。求助人本人才能按。 ACTION_RATE = "assistant.ticket.rate" #: 工单状态 → 卡片语义状态。**不是颜色映射**:状态决定头色、徽章和图标三样, #: 改这张表等于同时改三处(见 `card_design` 模块头)。 _STATUS_TONE: dict[str, str] = { "open": card_design.STATUS_PENDING, "triaging": card_design.STATUS_PENDING, "in_progress": card_design.STATUS_PENDING, "waiting_requester": card_design.STATUS_REMINDER, "resolved": card_design.STATUS_DONE, "closed": card_design.STATUS_DONE, "cancelled": card_design.STATUS_WITHDRAWN, } _TITLE: dict[Lang, str] = { "zh": "{number} {title}", "en": "{number} {title}", "ja": "{number} {title}", } _SUBTITLE: dict[Lang, str] = { "zh": "IT 求助工单 · 这个群在处理的就是它", "en": "IT support ticket · this room is about it", "ja": "IT 依頼チケット · このグループの対象です", } _STATUS_LABEL: dict[str, dict[Lang, str]] = { "open": {"zh": "待受理", "en": "Open", "ja": "未対応"}, "triaging": {"zh": "已认领", "en": "Triaging", "ja": "対応者決定"}, "in_progress": {"zh": "处理中", "en": "In progress", "ja": "対応中"}, "waiting_requester": { "zh": "等你回复", "en": "Waiting on requester", "ja": "依頼者の返信待ち", }, "resolved": {"zh": "已解决", "en": "Resolved", "ja": "解決済み"}, "closed": {"zh": "已关闭", "en": "Closed", "ja": "クローズ"}, "cancelled": {"zh": "已撤回", "en": "Withdrawn", "ja": "取り下げ"}, } _PRIORITY_LABEL: dict[str, dict[Lang, str]] = { "low": {"zh": "低", "en": "Low", "ja": "低"}, "normal": {"zh": "普通", "en": "Normal", "ja": "通常"}, "high": {"zh": "高", "en": "High", "ja": "高"}, "urgent": {"zh": "紧急", "en": "Urgent", "ja": "緊急"}, } _FACT_REQUESTER: dict[Lang, str] = {"zh": "求助人", "en": "Requester", "ja": "依頼者"} _FACT_ASSIGNEE: dict[Lang, str] = {"zh": "处理人", "en": "Assignee", "ja": "対応者"} _FACT_STATUS: dict[Lang, str] = {"zh": "状态", "en": "Status", "ja": "ステータス"} _FACT_PRIORITY: dict[Lang, str] = {"zh": "优先级", "en": "Priority", "ja": "優先度"} _UNASSIGNED: dict[Lang, str] = { "zh": "还没人认领", "en": "Nobody yet", "ja": "未割り当て", } _BTN_CLAIM: dict[Lang, str] = { "zh": "我来处理", "en": "I'll take it", "ja": "対応します", } _BTN_PRIORITY: dict[Lang, str] = { "zh": "标为高优先级", "en": "Raise priority", "ja": "優先度を上げる", } _BTN_REFRESH: dict[Lang, str] = { "zh": "刷新状态", "en": "Refresh", "ja": "更新", } _BTN_RESOLVE: dict[Lang, str] = { "zh": "我弄完了", "en": "It's fixed", "ja": "対応完了", } _BTN_CLOSE: dict[Lang, str] = { "zh": "问题解决了", "en": "Problem solved", "ja": "解決しました", } _BTN_REOPEN: dict[Lang, str] = { "zh": "还有问题", "en": "Still broken", "ja": "まだ問題があります", } _BTN_OPEN: dict[Lang, str] = { "zh": "在 Helix 里打开", "en": "Open in Helix", "ja": "Helix で開く", } #: 卡片底部那行提示。群里能打的命令没有别的地方会告诉你 —— 一个只能靠猜的 #: 命令面等于不存在。 _FOOTER: dict[Lang, str] = { "zh": ( "群里 @Helix 可以直接说:总结 · 转给 @某人 · 交接给 @某人 · " "解决了 <说明> · 优先级 高 · 状态" ), "en": ( "@-mention Helix here: summary · assign to @someone · hand over to " "@someone · resolved · priority high · status" ), "ja": ( "このグループで @Helix:まとめ · @担当者 に転送 · @誰か に引き継ぎ · " "解決 <説明> · 優先度 高 · ステータス" ), } def _pick(table: dict[Lang, str], lang: Lang) -> str: return table.get(lang) or table[DEFAULT_LANG] def _label(table: dict[str, dict[Lang, str]], key: str, lang: Lang) -> str: """枚举值 → 读者语言的文案,未知值原样返回。 原样返回而不是抛错:一个新增的状态值不该让整张卡片发不出去 —— 卡片渲染 路径上每一个失败都是「群里的人不知道这单怎么样了」。 """ entry = table.get(key) return _pick(entry, lang) if entry else key def build_ticket_card( *, ticket_number: str, ticket_id: uuid.UUID | str, title: str, status: str, priority: str, requester_name: str, assignee_name: str | None, lang: Lang = DEFAULT_LANG, portal_url: str | None = None, ) -> dict[str, Any]: """这张工单此刻的样子。 参数刻意是**平铺的值而不是 ORM 对象**:这张卡在两个地方被画 —— 建群时(工单 刚建好,还在调用方的 session 里)和状态变化后的后台刷新(另一个 session)。 传对象进来意味着第二处必须保证它没过期,而 `MissingGreenlet` 正是这条链路 最难查的那一类失败。 """ tone_status = _STATUS_TONE.get(status, card_design.STATUS_INFO) facts = [ CardFact( label=_pick(_FACT_STATUS, lang), value=_label(_STATUS_LABEL, status, lang), icon="action", ), CardFact( label=_pick(_FACT_ASSIGNEE, lang), value=assignee_name or _pick(_UNASSIGNED, lang), icon="person", ), CardFact( label=_pick(_FACT_REQUESTER, lang), value=requester_name or "—", icon="person", ), CardFact( label=_pick(_FACT_PRIORITY, lang), value=_label(_PRIORITY_LABEL, priority, lang), icon="warn" if priority in ("high", "urgent") else "action", ), ] buttons = _buttons_for( status=status, priority=priority, ticket_id=str(ticket_id), has_assignee=bool(assignee_name), lang=lang, ) return _build_interactive_card( title=_pick(_TITLE, lang).format( number=ticket_number, title=(title or "")[:60], ), body_text="", url=portal_url, button_label=_pick(_BTN_OPEN, lang), is_en=lang == "en", lang=lang, status=tone_status, subtitle=_pick(_SUBTITLE, lang), facts=facts, callback_actions=buttons, # 索引与 ``extra_actions`` 对齐:渲染器会丢掉解析后为空的那颗按钮, # 所以在通知管线之外调用时必须自己把 URL 传全(确认卡踩过同一处)。 extra_actions=( [CardAction(label=_pick(_BTN_OPEN, lang), url=portal_url)] if portal_url else None ), extra_action_urls=[portal_url] if portal_url else None, footer=_pick(_FOOTER, lang), ) #: 一行**最多三颗**。飞书的按钮行按可用宽度贪心排列,四颗在手机上会折行 —— #: 前三颗挤在第一行、第四颗孤零零掉到第二行(2026-09-07 用户的真机截图)。 #: 所以这里是一台状态机:每个状态只给出**此刻真正有用的**那两三颗。 _MAX_BUTTONS = 3 #: 终态 —— 这件事结束了,卡上除了「刷新」不该再有任何按钮。 _FINAL_STATUSES = frozenset({"closed", "cancelled"}) #: 球在求助人那边:该由他说「好了」还是「还有问题」。 _REQUESTER_TURN_STATUSES = frozenset({"waiting_requester", "resolved"}) def _buttons_for( *, status: str, priority: str, ticket_id: str, has_assignee: bool, lang: Lang, ) -> list[dict[str, Any]]: """此刻这张卡上该有哪几颗按钮。 按**状态**给,而不是把所有动作都摆上去:一颗按了必然被拒的按钮读起来像平台 坏了,而不像这件事已经结束了;而五颗按钮在手机上会折行成一堆。 ⚠️ 「已解决」不是终态:那时候正需要求助人说一句「确实好了」或者「还有问题」。 真正的终态只有 ``closed`` / ``cancelled``。 """ def _btn(table: dict[Lang, str], action: str, **extra: Any) -> dict[str, Any]: return _callback_button( _pick(table, lang), {"action": action, "ticket": ticket_id, **extra}, style="primary" if action in ( ACTION_CLAIM, ACTION_RESOLVE, ACTION_CLOSE, ) else "default", ) buttons: list[dict[str, Any]] = [] if status in _FINAL_STATUSES: pass elif status in _REQUESTER_TURN_STATUSES: buttons.append(_btn(_BTN_CLOSE, ACTION_CLOSE)) buttons.append(_btn(_BTN_REOPEN, ACTION_REOPEN)) elif not has_assignee: # 还没人认领:此刻有用的两件事是「我接」和「这个很急」。 buttons.append(_btn(_BTN_CLAIM, ACTION_CLAIM)) if priority not in ("high", "urgent"): buttons.append(_btn(_BTN_PRIORITY, ACTION_PRIORITY_UP)) else: # 有人在处理:此刻有用的是「我弄完了」,以及万一它其实很急。 buttons.append(_btn(_BTN_RESOLVE, ACTION_RESOLVE)) if priority not in ("high", "urgent"): buttons.append(_btn(_BTN_PRIORITY, ACTION_PRIORITY_UP)) buttons.append(_btn(_BTN_REFRESH, ACTION_REFRESH)) return buttons[:_MAX_BUTTONS] def _callback_button( label: str, value: dict[str, Any], *, style: str = "default", ) -> dict[str, Any]: """一颗回传给我们的按钮(卡片 2.0 的 ``behaviors`` 形状)。 ``value`` 只带标识符,不带任何凭据 —— 按下它的人是谁,由飞书验签过的信封 决定,不由卡片内容决定。这是整条卡片链路的授权前提。 """ return { "tag": "button", "text": {"tag": "plain_text", "content": label}, "type": style, "size": "small", "width": "fill", "behaviors": [{"type": "callback", "value": value}], } __all__ = [ "ACTION_CLAIM", "ACTION_CLOSE", "ACTION_PRIORITY_UP", "ACTION_RATE", "ACTION_REFRESH", "ACTION_REOPEN", "ACTION_RESOLVE", "ACTION_RESOLVE_CONFIRM", "build_rating_card", "build_resolution_proposal_card", "build_ticket_card", "rated_card", ] # ── 结案说明的确认卡 ──────────────────────────────────────────────── _PROPOSAL_TITLE: dict[Lang, str] = { "zh": "这样写进工单可以吗?", "en": "Record this as the resolution?", "ja": "この内容で記録しますか?", } _PROPOSAL_SUBTITLE: dict[Lang, str] = { "zh": "结案说明 · 确认后写进工单并标记已解决", "en": "Resolution · confirming writes it to the ticket and resolves it", "ja": "対応内容 · 確認するとチケットに記録し解決済みにします", } _PROPOSAL_FOOTER: dict[Lang, str] = { "zh": "想自己写就直接在群里说「解决了 <你的说明>」。", "en": "Prefer your own words? Say “resolved ” in the room.", "ja": "ご自身で書く場合は「解決 <説明>」とこのグループで送ってください。", } _BTN_RESOLVE_CONFIRM: dict[Lang, str] = { "zh": "就这样结案", "en": "Resolve with this", "ja": "この内容で解決", } def build_resolution_proposal_card( *, ticket_id: uuid.UUID | str, resolution: str, digest: str, lang: Lang = DEFAULT_LANG, ) -> dict[str, Any]: """把 AI 整理的结案说明**先给人看一眼**,再决定写不写。 为什么不直接结案:这段字会成为工单上的正式记录,而一段没人读过就写上去的 说明会被下一个人当成事实 —— 这条链路上所有 AI 产出的文本都按这条办。 ``digest`` 是这段文本的哈希,回传时校验:确保「你确认的」和「写进去的」 是同一段。和助手确认卡同一条规矩。 """ return _build_interactive_card( title=_pick(_PROPOSAL_TITLE, lang), body_text=resolution, url=None, button_label=_pick(_BTN_RESOLVE_CONFIRM, lang), is_en=lang == "en", lang=lang, status=card_design.STATUS_PENDING, subtitle=_pick(_PROPOSAL_SUBTITLE, lang), callback_actions=[_callback_button( _pick(_BTN_RESOLVE_CONFIRM, lang), { "action": ACTION_RESOLVE_CONFIRM, "ticket": str(ticket_id), "digest": digest, }, style="primary", )], footer=_pick(_PROPOSAL_FOOTER, lang), ) # ── 评价卡 ────────────────────────────────────────────────────────── _RATE_TITLE: dict[Lang, str] = { "zh": "这次处理得怎么样?", "en": "How did that go?", "ja": "今回の対応はいかがでしたか?", } _RATE_SUBTITLE: dict[Lang, str] = { "zh": "只有求助人本人能评 · 一张工单一次", "en": "Requester only · once per ticket", "ja": "依頼者のみ · チケットごとに 1 回", } #: 三档,和飞书服务台的口径对齐(1=不满意 / 2=一般 / 3=满意),这样两个入口 #: 的数直接可比 —— 换一套刻度等于让并行期的对比作废。 _RATE_OPTIONS: tuple[tuple[int, str, dict[Lang, str]], ...] = ( (3, "😊", {"zh": "满意", "en": "Good", "ja": "満足"}), (2, "😐", {"zh": "一般", "en": "OK", "ja": "普通"}), (1, "🙁", {"zh": "不满意", "en": "Poor", "ja": "不満"}), ) _RATED: dict[Lang, str] = { "zh": "谢谢,已经记下了({label})。", "en": "Thanks — recorded ({label}).", "ja": "ありがとうございます({label})。", } def build_rating_card( *, ticket_id: uuid.UUID | str, lang: Lang = DEFAULT_LANG, ) -> dict[str, Any]: """三档满意度,发在群里。 发在群里而不是私信:求助人就在这个房间里,而这张卡是这段处理的收尾 —— 让它 出现在同一条时间线上。按钮的授权由处理函数重新判断(只有求助人本人能评)。 """ return _build_interactive_card( title=_pick(_RATE_TITLE, lang), body_text="", url=None, button_label="", is_en=lang == "en", lang=lang, status=card_design.STATUS_REMINDER, subtitle=_pick(_RATE_SUBTITLE, lang), callback_actions=[ _callback_button( f"{emoji} {_pick(labels, lang)}", { "action": ACTION_RATE, "ticket": str(ticket_id), "score": score, }, ) for score, emoji, labels in _RATE_OPTIONS ], ) def rated_card(*, score: int, lang: Lang = DEFAULT_LANG) -> dict[str, Any]: """评过之后的那张卡 —— 按钮花掉了,留下评的是哪一档。 重画整张卡而不是回一句 toast:飞书把回调的返回当成这条消息的新内容,而一个 只剩三颗按钮的卡片会让人以为可以再评一次。 """ label = next( (_pick(labels, lang) for value, _emoji, labels in _RATE_OPTIONS if value == score), str(score), ) return _build_interactive_card( title=_pick(_RATE_TITLE, lang), body_text=_pick(_RATED, lang).format(label=label), url=None, button_label="", is_en=lang == "en", lang=lang, status=card_design.STATUS_DONE, )