"""工单群里的命令 —— 在房间里就能推进这张工单。 ## 为什么是命令,而不是全部交给模型 「认领」「转给 @张三」「优先级 高」这几件事有一个共同点:**它们不能有歧义**。 一个把「转给张三」理解成「给张三发条消息」的模型,代价是一张工单挂在错的人名下, 而没有人会立刻发现。所以这些走**字面命令表**,和「新话题」「转人工」同一条规矩: 一件必须做对的事,不能依赖模型今天状态如何,也不能在模型不可用时消失。 匹配不上的句子照常交给模型 —— 命令是快捷方式,不是围墙。 ## 授权:在这个房间里 ≠ 有权动这张工单 每一条写命令都重新问一次 `support.service.can_act_on_ticket`(IT 管理员,或者 这张工单的处理人)。群成员资格是**可读性**:一个被拉进来看一眼的同事,不该因此 获得改优先级或转派的权力。 拒绝时**说清楚谁可以**,而不是一句「无权操作」—— 后者会让人以为是平台坏了,而 不是以为这件事本来就不归他。 ## 参数来自验签过的信封,不来自正文 「转给 @张三」里的张三,取自 `event.message.mentions[].id.open_id`(飞书验签过 的身份),不是正文里那三个字。名字可以重、可以被伪造成别人的样子;``open_id`` 不能。 """ from __future__ import annotations import logging import re import uuid from dataclasses import dataclass from sqlalchemy.ext.asyncio import AsyncSession from app.assistant import group_chat, ticket_chat from app.assistant.channel import ReplyTarget from app.core.locale import DEFAULT_LANG, Lang from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) # ── 命令表 ────────────────────────────────────────────────────────── #: 「这单现在什么情况」。只读,所以谁都能问。 _STATUS_WORDS = frozenset({ "状态", "工单状态", "进度", "什么情况", "现在怎么样", "status", "ticket status", "progress", "ステータス", "状況", }) #: 「我来处理」。 _CLAIM_WORDS = frozenset({ "认领", "我来处理", "我来", "我跟进", "接单", "claim", "i'll take it", "ill take it", "take it", "対応します", "担当します", }) #: 「转给 @某人」。@ 之外的字随便怎么写,所以是前缀匹配而不是整句相等。 _ASSIGN_PREFIXES = ( "转给", "转派", "转交", "指派给", "指派", "交给", "assign to", "assign", "reassign to", "reassign", "hand over to", "転送", "担当変更", ) #: 「拉 @某人 进来」。 _INVITE_PREFIXES = ( "拉", "加", "叫", "请", "邀请", "把", "invite", "add", "pull in", "bring in", "追加", "招待", ) #: 优先级词表。key 是工单域的枚举值,value 是员工可能打出来的词。 _PRIORITY_WORDS: dict[str, tuple[str, ...]] = { "urgent": ("紧急", "特急", "urgent", "p0", "緊急"), "high": ("高", "高优", "高优先级", "加急", "high", "p1", "高い"), "normal": ("普通", "一般", "正常", "normal", "p2", "通常"), "low": ("低", "低优先级", "不急", "low", "p3", "低い"), } _PRIORITY_PREFIXES = ("优先级", "优先度", "priority", "優先度") #: 「总结一下」。这条是这套东西里信息价值最高的一条 —— 工单群里发生的才是真正 #: 的处理过程,而工单上原来只有最初那句提问和最后一句「已解决」。 _SUMMARY_WORDS = frozenset({ "总结", "总结一下", "小结", "梳理一下", "记一下", "总结下", "summary", "summarise", "summarize", "sum up", "recap", "まとめ", "まとめて", "要約", }) #: 「交接给 @某人」= 总结 + 转派 + 拉人。一条命令,因为交接本来就是这三件事; #: 分成三步做的结果是常有人只做了其中一步(通常是只改了 assignee)。 _HANDOVER_PREFIXES = ( "交接给", "交接", "移交给", "移交", "hand over to", "handover to", "handover", "hand off to", "引き継ぎ", "引継ぎ", ) #: 「解决了 <说明>」。**带说明的那条是主路径**:处理人自己写的一句话,永远比 #: AI 整理的那段更准 —— 而它同时是结案说明和这条链路上最有价值的知识语料。 _RESOLVED_PREFIXES = ( "解决了", "已解决", "搞定了", "修好了", "处理完了", "弄完了", "结案", "resolved", "fixed", "done", "解決しました", "解決", "対応完了", ) #: 「没解决」。已解决/已关闭的单子重开;还没解决的只记一句话。 _STILL_BROKEN_WORDS = frozenset({ "没解决", "还没解决", "还有问题", "又出现了", "还是不行", "没好", "not fixed", "still broken", "not resolved", "still not working", "まだ直っていません", "未解決", }) #: 「撤回」——求助人自己撤掉这张单。 _WITHDRAW_WORDS = frozenset({ "撤回", "撤单", "不用了", "算了", "取消工单", "withdraw", "never mind", "cancel the ticket", "取り下げ", "キャンセル", }) #: 「解散群」。**不可逆**:聊天记录随它一起没了 —— 所以它只在工单终态后放行, #: 而且解散前会保证工单上有一段过程记录。 _DISBAND_WORDS = frozenset({ "解散群", "解散", "关群", "散了吧", "把群解散", "disband", "close the room", "archive the room", "グループ解散", "解散して", }) #: 群里的帮助。**必须存在**:一个只能靠猜的命令面等于不存在。 _HELP_WORDS = frozenset({"帮助", "help", "/help", "怎么用", "ヘルプ"}) # ── 文案 ──────────────────────────────────────────────────────────── _REFUSED: dict[Lang, str] = { "zh": "这件事只有 IT 管理员或这张工单的处理人能做。现在的处理人是 {who}。", "en": "Only an IT admin or this ticket's assignee can do that. Right now that is {who}.", "ja": "これは IT 管理者または担当者のみ実行できます。現在の担当者は {who} です。", } _NOBODY: dict[Lang, str] = { "zh": "还没有人", "en": "nobody yet", "ja": "未割り当て", } _STATUS_LINE: dict[Lang, str] = { "zh": "{number} · 状态 {status} · 处理人 {assignee}", "en": "{number} · {status} · assignee {assignee}", "ja": "{number} · {status} · 担当 {assignee}", } _CLAIMED: dict[Lang, str] = { "zh": "好的,{who} 接手了这张工单。", "en": "Done — {who} has taken this ticket.", "ja": "承知しました。{who} さんが担当します。", } _ALREADY_YOURS: dict[Lang, str] = { "zh": "这张工单本来就在你名下。", "en": "This ticket is already yours.", "ja": "このチケットはすでにあなたの担当です。", } _ASSIGNED: dict[Lang, str] = { "zh": "已转给 {who}。", "en": "Reassigned to {who}.", "ja": "{who} さんに転送しました。", } _INVITED: dict[Lang, str] = { "zh": "已经把 {who} 拉进来了。", "en": "{who} is in the room now.", "ja": "{who} さんを追加しました。", } _PRIORITY_SET: dict[Lang, str] = { "zh": "优先级已改为 {level}。", "en": "Priority is now {level}.", "ja": "優先度を {level} に変更しました。", } _NEED_MENTION: dict[Lang, str] = { "zh": "要 @ 一下那个人我才知道是谁 —— 比如「转给 @张三」。", "en": "@-mention the person so I know who you mean — e.g. “assign to @Alice”.", "ja": "対象の方を @メンション してください(例:「@田中 に転送」)。", } _UNKNOWN_PERSON: dict[Lang, str] = { "zh": "我认不出 {who} 是 Helix 里的哪个人(可能还没在 Helix 登录过)。", "en": "I can't match {who} to a Helix person (they may never have signed in).", "ja": "{who} さんを Helix の人物と照合できませんでした。", } _TERMINAL: dict[Lang, str] = { "zh": "这张工单已经是 {status} 了,不能再改。", "en": "This ticket is already {status} — nothing left to change.", "ja": "このチケットはすでに {status} です。", } _HELP: dict[Lang, str] = { "zh": ( "在这个群里 @我 可以直接说:\n" "• 状态 —— 这单现在什么情况\n" "• 认领 —— 我来处理\n" "• 总结 —— 把群里的讨论记到工单上\n" "• 转给 @某人 —— 换处理人\n" "• 交接给 @某人 —— 总结 + 转派 + 拉他进来\n" "• 拉 @某人 进来 —— 把人加进这个群\n" "• 解决了 <说明> —— 结案(说明会写进工单)\n" "• 没解决 —— 重新打开\n" "• 解散群 —— 结了之后清掉这个房间(记录已经在工单上)\n" "• 优先级 高 / 紧急 / 普通 / 低\n" "其它问题照常问我就行。" ), "en": ( "@-mention me here and say:\n" "• status — where this ticket stands\n" "• claim — I'll take it\n" "• summary — write the discussion onto the ticket\n" "• assign to @someone — change the assignee\n" "• hand over to @someone — summary + reassign + invite\n" "• invite @someone — add them to this room\n" "• resolved — close it out (the text goes on the ticket)\n" "• not fixed — reopen it\n" "• disband — clear the room once it's settled (the record is on the ticket)\n" "• priority high / urgent / normal / low\n" "Anything else, just ask me normally." ), "ja": ( "このグループで @メンション して:\n" "• ステータス — 現在の状況\n" "• 対応します — 担当を引き受ける\n" "• まとめ — 議論をチケットに記録\n" "• @誰か に転送 — 担当者の変更\n" "• @誰か に引き継ぎ — まとめ + 転送 + 招待\n" "• @誰か を追加 — このグループに招待\n" "• 解決 <説明> — 解決済みにする(説明はチケットに記録)\n" "• 未解決 — 再オープン\n" "• グループ解散 — 完了後にこの部屋を片付ける\n" "• 優先度 高 / 緊急 / 通常 / 低\n" "その他のご質問は普通にどうぞ。" ), } _SUMMARY_UNAVAILABLE: dict[Lang, str] = { "zh": "AI 暂时不可用,这次没能总结。过一会儿再说一次「总结」就行 —— " "也可以直接在工单里写一段。", "en": "The model is unavailable right now, so I could not summarise. Say " "“summary” again in a bit, or write it on the ticket directly.", "ja": "AI が一時的に利用できず、まとめを作成できませんでした。しばらくして" "から「まとめ」ともう一度お伝えください。", } _SUMMARY_EMPTY: dict[Lang, str] = { "zh": "这个群里还没有可以总结的讨论。", "en": "There is no discussion in this room to summarise yet.", "ja": "まだまとめる議論がありません。", } _SUMMARY_UNREADABLE: dict[Lang, str] = { "zh": "我读不到这个群的历史消息(可能是权限还没开)。可以直接把要点发出来," "我记到工单上。", "en": "I cannot read this room's history (the scope may not be granted). " "Post the key points and I'll put them on the ticket.", "ja": "このグループの履歴を読み取れませんでした(権限が未付与の可能性)。" "要点をお送りいただければチケットに記録します。", } _SUMMARY_WRITTEN: dict[Lang, str] = { "zh": "已经记到工单 {number} 上了。", "en": "Written onto ticket {number}.", "ja": "チケット {number} に記録しました。", } _RESOLVED_OK: dict[Lang, str] = { "zh": "已标记为解决,结案说明写进工单了。等 {who} 确认就关单。", "en": "Marked resolved and the write-up is on the ticket. It closes once " "{who} confirms.", "ja": "解決済みにし、対応内容をチケットに記録しました。{who} さんの確認後に" "クローズします。", } _NEED_RESOLUTION_TEXT: dict[Lang, str] = { "zh": "说一句「解决了 <怎么解决的>」我就写进工单 —— 那一句会成为这张工单的" "结案说明,也是以后同类问题的答案。", "en": "Say “resolved ” and I'll put it on the ticket — that " "line becomes the resolution, and the answer for the next person " "with the same problem.", "ja": "「解決 <対応内容>」とお送りいただければチケットに記録します。", } _WITHDRAWN: dict[Lang, str] = { "zh": "工单已撤回。", "en": "The ticket is withdrawn.", "ja": "チケットを取り下げました。", } _ONLY_REQUESTER: dict[Lang, str] = { "zh": "这件事只有求助人本人能做。", "en": "Only the requester can do that.", "ja": "これは依頼者ご本人のみ実行できます。", } _REOPENED: dict[Lang, str] = { "zh": "已重新打开,{who} 会继续跟。", "en": "Reopened — {who} will pick it back up.", "ja": "再オープンしました。{who} さんが対応します。", } _NOTED_STILL_BROKEN: dict[Lang, str] = { "zh": "记下了 —— 这张工单还没标记解决,所以它本来就还在处理中。", "en": "Noted — the ticket was never marked resolved, so it is still open.", "ja": "記録しました。このチケットは未解決のままです。", } _DISBAND_TOO_EARLY: dict[Lang, str] = { "zh": "这张工单还是 {status},先把它结掉再解散群 —— 群的聊天记录会随解散" "一起消失,而这件事还没有结论。", "en": "The ticket is still {status}. Close it first — this room's history " "disappears with it, and this is not settled yet.", "ja": "チケットは {status} です。先にクローズしてください —— 解散すると" "履歴も失われます。", } _DISBAND_DONE: dict[Lang, str] = { "zh": "好的,这个群解散了。", "en": "Done — disbanding this room.", "ja": "承知しました。このグループを解散します。", } _DISBAND_FAILED: dict[Lang, str] = { "zh": "暂时没能解散这个群,过一会儿它也会被自动清理。", "en": "Could not disband the room right now — the daily sweep will get it.", "ja": "現在解散できませんでした。日次の整理で処理されます。", } _DISBAND_NOT_YOURS: dict[Lang, str] = { "zh": "解散群只有求助人或这张工单的处理人能做。", "en": "Only the requester or this ticket's assignee can disband the room.", "ja": "解散は依頼者または担当者のみ実行できます。", } _HANDED_OVER: dict[Lang, str] = { "zh": "已交接给 {who} —— 讨论小结已经写进工单,人也拉进群了。", "en": "Handed over to {who} — the summary is on the ticket and they are in " "the room.", "ja": "{who} さんに引き継ぎました。まとめをチケットに記録し、グループにも" "追加しました。", } _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": "緊急"}, } _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": "取り下げ"}, } _TERMINAL_STATUSES = frozenset({"resolved", "closed", "cancelled"}) 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 @dataclass(frozen=True, slots=True) class CommandResult: """一条命令的结果。``outcome`` 进审计,``reply`` 是群里那句话。""" outcome: str reply: str #: 开头那一串 @某人。**只吃开头的** —— 「转给 @张三」里的那个 @ 是这句话的宾语。 _LEADING_MENTIONS = re.compile(r"^(?:[@@][^\s@@ ]+[\s ]*)+") def drop_leading_mentions(text: str) -> str: """去掉句首的 @某人。 🔴 **为什么命令表不能依赖「@机器人 已经被摘掉了」。** 正文里的 @ 由 ``group_chat.strip_bot_mention`` 处理,而它认机器人靠的是机器人自己的 ``open_id``;那个 id 取不到时(2026-09-08 生产上就是这样:``/bot/v3/info`` 的回包没有 ``data`` 信封,于是身份永远解析不出来),这次 @ 会被保留成 ``@Helix``。于是员工发的 `@Helix 总结` 到了这里是 ``"@Helix 总结"`` —— **一条命令都对不上**,群里每一条字面命令都静默落到通用问答,而员工看到的是 「我无法查看群聊内容」。 所以这里再兜一层:命令永远不会**以**一个有意义的 @ 开头,而句中的 @ (「转给 @张三」)一个都不动。上游修好之后这一层是纯冗余 —— 而这正是它该有的 样子:一条命令不该因为谁的 id 没解析出来就不工作。 """ return _LEADING_MENTIONS.sub("", text or "", count=1).strip() def _normalise(text: str) -> str: """比较用的形式:去掉句首 @、去空白、小写、去掉结尾标点。 员工打的是「认领!」「claim.」「状态?」,而命令表里存的是干净词 —— 不做这一步 的结果是命令**看起来不工作**,而那比没有命令更糟。 """ cleaned = re.sub(r"[\s ]+", " ", drop_leading_mentions(text)).strip( " 。.!!??::,,、", ) return cleaned.lower() def _strip_prefix(text: str, prefixes: tuple[str, ...]) -> str: """去掉命令词,留下**原文**里剩下的那部分。 在原文上做,而不是在归一化后的 body 上:大小写、标点、专有名词、错误码都是 结案说明的一部分 —— 而 ``_normalise`` 会把它们统统压平。 """ stripped = drop_leading_mentions(text) lowered = stripped.lower() for prefix in prefixes: if lowered.startswith(prefix.lower()): return stripped[len(prefix):].strip(" ::,,、。.-—") return "" def looks_like_command(text: str) -> bool: """这句话是不是一条工单命令(用于决定要不要走模型)。""" body = _normalise(text) if not body: return False if body in _STATUS_WORDS or body in _CLAIM_WORDS or body in _HELP_WORDS: return True if ( body in _SUMMARY_WORDS or body in _STILL_BROKEN_WORDS or body in _WITHDRAW_WORDS or body in _DISBAND_WORDS ): return True return any( body.startswith(prefix) for prefix in ( *_RESOLVED_PREFIXES, *_HANDOVER_PREFIXES, *_ASSIGN_PREFIXES, *_INVITE_PREFIXES, *_PRIORITY_PREFIXES, ) ) def _priority_from(text: str) -> str | None: """「优先级 高」→ ``high``。整句里出现哪个词就是哪个。""" body = _normalise(text) if not any(body.startswith(p) for p in _PRIORITY_PREFIXES): return None tail = body for prefix in _PRIORITY_PREFIXES: if tail.startswith(prefix): tail = tail[len(prefix):].strip(" ::=") break for level, words in _PRIORITY_WORDS.items(): if tail in words or any(tail.startswith(w) for w in words): return level return None async def _people_from_mentions( db: AsyncSession, mentions: tuple[group_chat.Mention, ...], *, bot_id: str | None, ) -> tuple[list[IdentityPerson], list[str]]: """@ 到的人 → Helix 的人。返回 ``(认出来的, 没认出来的名字)``。 身份取自 ``open_id``(飞书验签过的),不是正文里的名字。先按登录身份查, 查不到再用通讯录把 ``open_id`` 换成邮箱、再按邮箱查 —— 被 @ 的人不一定 在 Helix 登录过,但他一定在通讯录里。 """ from sqlalchemy import select # noqa: PLC0415 from app.assistant import channel # noqa: PLC0415 from app.assistant.identity import resolve_sender # noqa: PLC0415 people: list[IdentityPerson] = [] unknown: list[str] = [] client = None for mention in mentions: if bot_id is not None and mention.open_id == bot_id: continue resolved = await resolve_sender( db, union_id=None, open_id=mention.open_id, ) if resolved.person is not None: people.append(resolved.person) continue if client is None: client = await channel.build_client(db) email = None if client is not None: try: email = await client.resolve_email_by_open_id(mention.open_id) except Exception as exc: # noqa: BLE001 — 认不出就是认不出 logger.info("assistant: mention lookup failed: %s", exc) person = None if email: person = (await db.execute( select(IdentityPerson).where( IdentityPerson.work_email == email, IdentityPerson.deleted_at.is_(None), ) )).scalars().first() if person is not None: people.append(person) else: unknown.append(mention.name or mention.open_id) return people, unknown async def _summarise_into_ticket( db: AsyncSession, *, chat, ticket, actor: IdentityPerson, lang: Lang, trace_id: str, ) -> tuple[str | None, str]: """读一次群 → 总结 → 写进工单时间线。 返回 ``(总结正文, 说给群里听的话)``。总结为 ``None`` 时**什么都不写**: 一段编出来的小结会被下一个人当成事实,而没有人会回头去核。 读群历史是一次**被叫到才发生**的动作,所以它自己落一行审计 —— 谁让读的、 读了哪个房间、多少条,**不含正文**。 """ from app.assistant import room_history, room_summary # noqa: PLC0415 from app.audit.service import AuditService # noqa: PLC0415 from app.domains.support.service import SupportService # noqa: PLC0415 # 刻意**不传** ``since``:一个 Helix 为这张工单开的房间,它的全部历史就是 # 这张工单的讨论 —— 没有更早的东西可排除。而读 ``chat.created_at`` 会踩一个 # 具体的坑:那是 server_default 列,行是在本 session 里 insert 的时候它从未 # 被取回过,一读就是一次惰性 IO —— 在异步路径上直接 ``MissingGreenlet``。 transcript = await room_history.read_room(db, chat_id=chat.feishu_chat_id) await AuditService(db).log( trace_id=trace_id or str(uuid.uuid4()), actor_type="user", actor_user_id=actor.id, action="assistant.chat.history_read", resource_type="assistant_group_chat", resource_id=chat.feishu_chat_id, status="success" if transcript is not None else "failed", detail={ # 正文不进审计。这里记的是「读了多少」,那正是「它有没有在偷听」 # 这个问题需要的证据。 "messages": len(transcript.messages) if transcript else 0, "truncated": bool(transcript.truncated) if transcript else False, "ticket_id": str(ticket.id), }, ) if transcript is None: return None, _pick(_SUMMARY_UNREADABLE, lang) if not transcript.human_messages: return None, _pick(_SUMMARY_EMPTY, lang) summary = await room_summary.summarize( db, transcript=transcript, ticket_title=ticket.title, lang=lang, ) if summary is None: return None, _pick(_SUMMARY_UNAVAILABLE, lang) text = summary.render(lang) # 群里发的和写进工单的**逐字相同**:两个版本的总结会让「群里说的」和 # 「台账上写的」对不上,而那正是台账存在的意义。 await SupportService(db).add_note( ticket=ticket, author=actor, body=text, trace_id=trace_id, ) return text, text async def try_handle( db: AsyncSession, *, text: str, actor: IdentityPerson, context: group_chat.GroupContext, mentions: tuple[group_chat.Mention, ...], bot_id: str | None, lang: Lang, target: ReplyTarget, trace_id: str, ) -> CommandResult | None: """认出并执行一条工单命令。不是命令时返回 ``None``(交给模型)。 每一条写命令自己校验授权 —— 「在这个房间里」不是任何权力的来源。 """ from app.assistant import channel # noqa: PLC0415 from app.core.exceptions import AppException # noqa: PLC0415 from app.domains.support.models import SupportTicket # noqa: PLC0415 from app.domains.support.service import ( # noqa: PLC0415 SupportService, can_act_on_ticket, role_names_for_person, ) if not context.is_handoff or not looks_like_command(text): return None chat = context.ticket_chat ticket = await db.get(SupportTicket, chat.ticket_id) if ticket is None: # 群还在,工单没了(硬删)。如实说不出话来 —— 而不是假装执行成功。 logger.warning( "assistant: ticket %s behind chat %s is gone", chat.ticket_id, chat.feishu_chat_id, ) return None body = _normalise(text) assignee = ( await db.get(IdentityPerson, ticket.assignee_id) if ticket.assignee_id else None ) assignee_name = ( (assignee.display_name or assignee.work_email) if assignee else _pick(_NOBODY, lang) ) async def _say(message: str) -> None: await channel.send_text(db, to=target, text=message) # ── 只读命令:谁都能问 ───────────────────────────────────────── if body in _HELP_WORDS: await _say(_pick(_HELP, lang)) return CommandResult("ticket_help", _pick(_HELP, lang)) if body in _STATUS_WORDS: line = _pick(_STATUS_LINE, lang).format( number=ticket.ticket_number, status=_label(_STATUS_LABEL, ticket.status, lang), assignee=assignee_name, ) await _say(line) return CommandResult("ticket_status", line) if body in _SUMMARY_WORDS: # 只读群 + 往工单加一条记录。三条刻意的选择: # # * **不要求处理权** —— 把讨论记到台账上是这个房间里任何人都该能做的 # 事,而它不改变工单的状态或归属。 # * **排在终态闸之前** —— 结案复盘正是最该留下过程的时候,对一张已关闭 # 的工单说「不能再改」会把这件事整个挡掉。 # * 总结不出来时**什么都不写**(见 ``_summarise_into_ticket``)。 summary_text, said = await _summarise_into_ticket( db, chat=chat, ticket=ticket, actor=actor, lang=lang, trace_id=trace_id, ) await db.commit() await _say(said) if summary_text is not None: await _say(_pick(_SUMMARY_WRITTEN, lang).format( number=ticket.ticket_number, )) return CommandResult("ticket_summarised", summary_text) return CommandResult("ticket_summary_unavailable", said) # ── 求助人那两句:判据是「这是你的工单」,不是「你是处理人」 ────── # # 「没解决」和「撤回」由求助人说才算 —— 处理人替他说第一句,等于自己给 # 自己发合格证。IT 管理员放行(后台一直如此)。 if body in _STILL_BROKEN_WORDS or body in _WITHDRAW_WORDS: roles_now = await role_names_for_person(db, actor.id) is_requester = ticket.requester_id == actor.id if not is_requester and "global_admin" not in roles_now: message = _pick(_ONLY_REQUESTER, lang) await _say(message) return CommandResult("ticket_not_requester", message) svc = SupportService(db) if body in _WITHDRAW_WORDS: try: await svc.cancel( ticket=ticket, requester=actor, trace_id=trace_id, ) except AppException: message = _pick(_TERMINAL, lang).format( status=_label(_STATUS_LABEL, ticket.status, lang), ) await _say(message) return CommandResult("ticket_terminal", message) await db.commit() message = _pick(_WITHDRAWN, lang) await _say(message) return CommandResult("ticket_withdrawn", message) if ticket.status in ("resolved", "closed"): await svc.reopen( ticket=ticket, actor=actor, is_agent=not is_requester, trace_id=trace_id, ) await db.commit() message = _pick(_REOPENED, lang).format(who=assignee_name) await _say(message) return CommandResult("ticket_reopened", message) # 还没标记解决的单子上说「还有问题」——没有什么可重开的,但这句话本身 # 是信息:记一条,别把它变成一次「无效操作」的拒绝。 await svc.add_note( ticket=ticket, author=actor, body=_pick(_NOTED_STILL_BROKEN, lang), trace_id=trace_id, ) await db.commit() message = _pick(_NOTED_STILL_BROKEN, lang) await _say(message) return CommandResult("ticket_still_broken_noted", message) if body in _DISBAND_WORDS: # **不可逆**:聊天记录随房间一起消失。三道闸,顺序也是刻意的: # # 1. **工单必须已经结了** —— 先判这个,因为对一件还没有结论的事说 # 「你没权限解散」是误导:有权限的人来了也一样不该解散。 # 2. **求助人或处理人**(管理员亦可)。 # 3. 解散前保证工单上有一段过程记录(见 ``_summarise_into_ticket``)。 if ticket.status not in _TERMINAL_STATUSES: message = _pick(_DISBAND_TOO_EARLY, lang).format( status=_label(_STATUS_LABEL, ticket.status, lang), ) await _say(message) return CommandResult("ticket_disband_too_early", message) roles_now = await role_names_for_person(db, actor.id) may_disband = ( ticket.requester_id == actor.id or can_act_on_ticket( ticket=ticket, person_id=actor.id, role_names=roles_now, ) ) if not may_disband: message = _pick(_DISBAND_NOT_YOURS, lang) await _say(message) return CommandResult("ticket_disband_refused", message) await _summarise_into_ticket( db, chat=chat, ticket=ticket, actor=actor, lang=lang, trace_id=trace_id, ) ok = await ticket_chat.disband( db, chat=chat, ticket=ticket, reason="requested", lang=lang, actor_person_id=actor.id, trace_id=trace_id, ) await db.commit() if not ok: message = _pick(_DISBAND_FAILED, lang) await _say(message) return CommandResult("ticket_disband_failed", message) return CommandResult("ticket_disbanded", _pick(_DISBAND_DONE, lang)) # ── 写命令:先问授权,再问内容 ─────────────────────────────── roles = await role_names_for_person(db, actor.id) allowed = can_act_on_ticket( ticket=ticket, person_id=actor.id, role_names=roles, ) async def _refuse() -> CommandResult: message = _pick(_REFUSED, lang).format(who=assignee_name) await _say(message) return CommandResult("ticket_command_refused", message) if ticket.status in _TERMINAL_STATUSES: # 终态的工单上没有可改的东西。这条在授权之前判:对一个已经关掉的工单说 # 「你没有权限」是误导 —— 有权限的人来了也一样改不了。 message = _pick(_TERMINAL, lang).format( status=_label(_STATUS_LABEL, ticket.status, lang), ) await _say(message) return CommandResult("ticket_terminal", message) if body in _CLAIM_WORDS: if ticket.assignee_id == actor.id: message = _pick(_ALREADY_YOURS, lang) await _say(message) return CommandResult("ticket_claim_noop", message) if not allowed: return await _refuse() await SupportService(db).claim( ticket=ticket, agent=actor, trace_id=trace_id, ) await db.commit() message = _pick(_CLAIMED, lang).format( who=actor.display_name or actor.work_email or "", ) await _say(message) return CommandResult("ticket_claimed", message) if any(body.startswith(p) for p in _HANDOVER_PREFIXES): if not allowed: return await _refuse() people, unknown = await _people_from_mentions(db, mentions, bot_id=bot_id) if not people: message = ( _pick(_UNKNOWN_PERSON, lang).format(who="、".join(unknown)) if unknown else _pick(_NEED_MENTION, lang) ) await _say(message) return CommandResult("ticket_command_no_target", message) successor = people[0] # 交接 = 总结 + 转派 + 拉人。三件事一条命令,因为分开做的结果是常有人 # 只做了其中一件(通常是只改了 assignee),而接手的人拿到的是一张 # 没有过程、也进不去现场的工单。 summary_text, note = await _summarise_into_ticket( db, chat=chat, ticket=ticket, actor=actor, lang=lang, trace_id=trace_id, ) await SupportService(db).assign( ticket=ticket, assignee=successor, actor=actor, trace_id=trace_id, ) await ticket_chat.invite( db, chat=chat, people=[successor], actor=actor, lang=lang, ) await db.commit() if summary_text is not None: await _say(summary_text) else: # 总结没做成也照样交接 —— 但要说清楚少了什么,别让接手的人以为 # 工单上有一段他还没读的过程记录。 await _say(note) message = _pick(_HANDED_OVER, lang).format( who=successor.display_name or successor.work_email or "", ) await _say(message) return CommandResult("ticket_handed_over", message) if any(body.startswith(p) for p in _RESOLVED_PREFIXES): if not allowed: return await _refuse() # 说明取的是**原文**(不是归一化过的 body):大小写、标点、专有名词都是 # 结案说明的一部分,而这段字会成为工单上的正式记录。 resolution = _strip_prefix(text, _RESOLVED_PREFIXES) if not resolution: # 光说「解决了」不够 —— 那一句会成为这张工单的结案说明,也是以后 # 同类问题的答案。空的结案说明等于没有结案说明。 message = _pick(_NEED_RESOLUTION_TEXT, lang) await _say(message) return CommandResult("ticket_resolution_missing", message) await SupportService(db).resolve( ticket=ticket, agent=actor, resolution=resolution, trace_id=trace_id, ) await db.commit() requester = await db.get(IdentityPerson, ticket.requester_id) message = _pick(_RESOLVED_OK, lang).format( who=(requester.display_name or requester.work_email) if requester else "", ) await _say(message) return CommandResult("ticket_resolved", message) level = _priority_from(body) if level is not None: if not allowed: return await _refuse() await SupportService(db).set_attributes( ticket=ticket, actor=actor, priority=level, trace_id=trace_id, ) await db.commit() message = _pick(_PRIORITY_SET, lang).format( level=_label(_PRIORITY_LABEL, level, lang), ) await _say(message) return CommandResult("ticket_priority", message) is_assign = any(body.startswith(p) for p in _ASSIGN_PREFIXES) is_invite = not is_assign and any( body.startswith(p) for p in _INVITE_PREFIXES ) if not (is_assign or is_invite): return None people, unknown = await _people_from_mentions(db, mentions, bot_id=bot_id) if not people: message = ( _pick(_UNKNOWN_PERSON, lang).format(who="、".join(unknown)) if unknown else _pick(_NEED_MENTION, lang) ) await _say(message) return CommandResult("ticket_command_no_target", message) if is_assign: if not allowed: return await _refuse() new_owner = people[0] await SupportService(db).assign( ticket=ticket, assignee=new_owner, actor=actor, trace_id=trace_id, ) # 转派 = 那个人得进这个房间。只改字段不拉人,等于把活交给一个看不见 # 现场的人 —— 而现场就是这个群。 await ticket_chat.invite( db, chat=chat, people=[new_owner], actor=actor, lang=lang, ) await db.commit() message = _pick(_ASSIGNED, lang).format( who=new_owner.display_name or new_owner.work_email or "", ) await _say(message) return CommandResult("ticket_assigned", message) # 拉人进群:**不**要求处理权。把一个懂行的同事拉进来看一眼,是这个房间存在 # 的意义之一;而拉人不改变任何人对这张工单的权力(见模块头)。 invited = await ticket_chat.invite( db, chat=chat, people=people, actor=actor, lang=lang, ) await db.commit() if not invited: message = _pick(_UNKNOWN_PERSON, lang).format( who="、".join(p.display_name or "" for p in people), ) await _say(message) return CommandResult("ticket_invite_failed", message) message = _pick(_INVITED, lang).format( who="、".join(p.display_name or p.work_email or "" for p in invited), ) await _say(message) return CommandResult("ticket_invited", message) __all__ = ["CommandResult", "looks_like_command", "try_handle"]