"""工单群:开一个、往里加人、让那张卡跟着工单变。 ## 这个模块是「工单 ↔ 群」的唯一入口 三个面都能开群 —— 助手的「转人工」按钮、求助人在工单详情页、处理人在后台队列 —— 但**建群这件事只有这里做**。三份拷贝会各自漂:一份忘了加处理人、一份忘了发卡、 一份忘了记台账,而每一种都要等到有人在群里干等的时候才被发现。 ## 幂等是硬要求,因为建群不可撤销 `assistant_ticket_chats.ticket_id` 唯一。第二次点「开群」拿到的是同一个群:两个 讲同一件事的房间会让参与的人各说各话,最后没有一个房间是完整的 —— 比没有群更糟。 ## 失败一律降级,不上抛 开群是一次外部调用;它失败时工单**已经在**了。所以这里的每个函数要么返回结果、 要么返回 ``None``,不抛给调用方 —— 一次飞书抖动不该让「提交工单」这件事失败。 ## 群里的卡片怎么跟着工单变 工单状态在别处变了(后台、门户、MCP),群里那张卡靠 ``PATCH /im/v1/messages/{id}`` 原地重画。刷新**永远重新读当前状态**,而不是把 「刚才发生了什么」画上去 —— 于是一次失败的刷新只是慢一步,绝不会让群里挂着一张 描述从未发生过的事的卡。 """ from __future__ import annotations import logging import uuid from dataclasses import dataclass from datetime import UTC, datetime from typing import Any from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.assistant import channel, ticket_card from app.assistant.channel import ReplyTarget from app.assistant.models import ( CHAT_REASON_HANDOFF, CHAT_STATE_CLOSED, CHAT_STATE_OPEN, AssistantTicketChat, ) from app.core.locale import DEFAULT_LANG, Lang, resolve_lang from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) #: 群名。带上工单号,这样负责人在会话列表里一眼知道是哪一单 —— 而不是三个 #: 「IT 求助 · 权限 · 张三」堆在一起分不出来。 _CHAT_NAME: dict[Lang, str] = { "zh": "IT 求助 · {number} · {who}", "en": "IT help · {number} · {who}", "ja": "IT 相談 · {number} · {who}", } _OPENED_NOTE: dict[Lang, str] = { "zh": "{who} 为工单 {number} 开了这个群,下面这张卡就是这张工单。", "en": "{who} opened this room for ticket {number}. The card below IS that ticket.", "ja": "{who} さんがチケット {number} のためにこのグループを作成しました。" "下のカードがそのチケットです。", } _INVITED_NOTE: dict[Lang, str] = { "zh": "{who} 把 {name} 拉进了群。", "en": "{who} invited {name} to this room.", "ja": "{who} さんが {name} さんを追加しました。", } #: 工单在**群外**变了状态时,群里留的那一句话。 #: #: 为什么必须有:卡片会原地变,但一张悄悄换了颜色的卡不会把人叫回来 —— 群里的 #: 人下次抬头才发现「已经解决了」,而中间他们一直在等。一句话是「这件事刚刚 #: 发生了」,卡片是「它现在是什么状态」,两者不能互相替代。 #: #: 按 key 存而不是让调用方传中文:这句话由后台任务渲染,而那里才知道读者的语言。 NOTE_TEXTS: dict[str, dict[Lang, str]] = { "claimed": { "zh": "{who} 认领了这张工单。", "en": "{who} picked this ticket up.", "ja": "{who} さんがこのチケットを担当します。", }, "assigned": { "zh": "工单已转给 {name}。", "en": "Reassigned to {name}.", "ja": "{name} さんに転送しました。", }, "attributes": { "zh": "工单属性已更新:{detail}。", "en": "Ticket updated: {detail}.", "ja": "チケットを更新しました:{detail}。", }, "agent_reply": { "zh": "{who} 在工单里回复了 —— 也可以直接在这个群里说。", "en": "{who} replied on the ticket — you can also just talk here.", "ja": "{who} さんがチケットに返信しました。このグループでも構いません。", }, "resolved": { "zh": "{who} 把这张工单标记为已解决。", "en": "{who} marked this ticket resolved.", "ja": "{who} さんがこのチケットを解決済みにしました。", }, "reopened": { "zh": "{who} 重新打开了这张工单。", "en": "{who} reopened this ticket.", "ja": "{who} さんがこのチケットを再オープンしました。", }, "closed": { "zh": "工单已关闭。", "en": "This ticket is closed.", "ja": "このチケットはクローズされました。", }, "cancelled": { "zh": "求助人撤回了这张工单。", "en": "The requester withdrew this ticket.", "ja": "依頼者がこのチケットを取り下げました。", }, # ── SLA 催办在群里的那句话 ──────────────────────────────────────── # # 为什么群里也要说:催办卡片只发给处理人一个人,而这件事的另一方(求助人) # 就在同一个房间里等着。一条只有处理人看见的催办,对员工而言与「没人管」 # 没有区别 —— 而他会另开一张工单。 "sla_unassigned": { "zh": "⏰ {number} 还没有人认领。说一句「认领」就可以接手。", "en": "⏰ {number} has nobody on it yet. Say “claim” to take it.", "ja": "⏰ {number} は未割り当てです。「対応します」で担当できます。", }, "sla_first_response_due": { "zh": "⏰ {number} 的首次响应时间快到了。", "en": "⏰ {number} is close to its first-response deadline.", "ja": "⏰ {number} の初回応答期限が近づいています。", }, "sla_resolution_due": { "zh": "⏰ {number} 的解决时限快到了。", "en": "⏰ {number} is close to its resolution deadline.", "ja": "⏰ {number} の解決期限が近づいています。", }, "sla_generic": { "zh": "⏰ {number} 需要有人推进一下。", "en": "⏰ {number} needs somebody to move it along.", "ja": "⏰ {number} の対応をお願いします。", }, "sla_escalated": { "zh": "已经催过几轮还没有进展,把 {who} 也拉进来看一下 {number}。" "(处理人不变。)", "en": "Several nudges with no movement, so {who} has been added to look " "at {number}. (The assignee is unchanged.)", "ja": "数回の催促でも進展がないため、{who} さんを {number} の確認に" "追加しました。(担当者は変更していません。)", }, } def render_note(key: str, lang: Lang, /, **params: object) -> str: """把一个 note key 渲染成读者语言的一句话。 未知 key 返回空串(调用方据此不发)——一个拼错的 key 不该在群里发出一行 ``{who} 做了 None``。 """ table = NOTE_TEXTS.get(key) if not table: return "" template = _pick(table, lang) # 🔴 **`str.format` 会静默丢掉模板里没有槽位的关键字。** 本仓 #3515 就是 # 这个形状:一张卡承诺给出工单号,而模板里没有 `{number}`,于是那句话渲染 # 成了空值 —— 读起来完全像用上了。所以这里反过来查一遍:传了参数而模板里 # 没有它,说明这两处已经漂了,记一行日志(而不是发出一句缺了字的话)。 missing_slots = [ name for name in params if "{" + name + "}" not in template ] if missing_slots: logger.warning( "assistant: ticket note %r (%s) has no slot for %s — the text and " "its caller have drifted", key, lang, ", ".join(sorted(missing_slots)), ) try: return template.format(**params) except (KeyError, IndexError): # ``str.format`` 缺槽位时会抛;这里宁可少一句话,也不要在群里发半句。 logger.warning("assistant: ticket note %r missing params %r", key, params) return "" def _pick(table: dict[Lang, str], lang: Lang) -> str: return table.get(lang) or table[DEFAULT_LANG] @dataclass(frozen=True, slots=True) class TicketChatResult: """开群的结果。``created`` 为假表示「本来就有,这是同一个群」。""" chat: AssistantTicketChat created: bool @property def applink(self) -> str: return chat_applink(self.chat.feishu_chat_id) def chat_applink(chat_id: str) -> str: """点开即进群的飞书 applink。 ``openChatId`` 是群的形式(``openId`` 是单聊的);工单台账页上那颗「进群」 按钮和卡片上的链接都用它。 """ return f"https://applink.feishu.cn/client/chat/open?openChatId={chat_id}" # ── 读 ────────────────────────────────────────────────────────────── async def get_by_ticket( db: AsyncSession, ticket_id: uuid.UUID | str, ) -> AssistantTicketChat | None: return (await db.execute( select(AssistantTicketChat).where( AssistantTicketChat.ticket_id == ticket_id, ) )).scalars().first() async def get_by_chat( db: AsyncSession, feishu_chat_id: str, ) -> AssistantTicketChat | None: if not feishu_chat_id: return None return (await db.execute( select(AssistantTicketChat).where( AssistantTicketChat.feishu_chat_id == feishu_chat_id, ) )).scalars().first() # ── 开群 ──────────────────────────────────────────────────────────── #: 房间的两次不可逆事件。**建群**会出现在每个成员的会话列表里,**解散**会 #: 把整段聊天记录一起带走 —— 而「谁解散的那个群」在没有审计行的时候是**问不 #: 出来**的:台账上只留着一个 ``close_reason``,而它对「到期清理」和「有人手 #: 敲了一句解散」给出同一个答案。平台的第 6 条不变量(每个关键动作都可追,带 #: trace_id)在这两件事上不能有例外。 ACTION_ROOM_OPENED = "assistant.room.opened" ACTION_ROOM_DISBANDED = "assistant.room.disbanded" async def _audit_room( db: AsyncSession, *, action: str, chat: AssistantTicketChat, ticket: Any, actor_person_id: Any | None, detail: dict[str, Any], trace_id: str | None, ) -> None: """一行审计。**不提交** —— 调用方紧接着自己 commit,而在这里提交会把调用方 还没写完的那一半(房间状态、工单备注)提前落一半下去。 SAVEPOINT 隔离:审计写失败绝不能把整条链路带走 —— 群已经建好了 / 已经删掉 了,那是既成事实,而一个失败的 flush 会让后面每一次读都炸在同一个事务上。 """ from app.audit.service import AuditService # noqa: PLC0415 from app.core.txn import best_effort # noqa: PLC0415 async with best_effort(db, label="assistant room audit", log=logger): await AuditService(db).log( trace_id=trace_id or str(uuid.uuid4()), actor_type="user" if actor_person_id is not None else "system", actor_user_id=actor_person_id, action=action, resource_type="assistant_ticket_chat", resource_id=str(chat.id), status="success", detail={ "feishu_chat_id": chat.feishu_chat_id, "ticket_id": str(getattr(ticket, "id", "") or ""), "ticket_number": getattr(ticket, "ticket_number", None), **detail, }, ) async def open_for_ticket( db: AsyncSession, *, ticket: Any, requester: IdentityPerson, members: list[IdentityPerson], opened_by: IdentityPerson | None, reason: str = CHAT_REASON_HANDOFF, portal_url: str | None = None, intro_text: str | None = None, forward_feishu_message_id: str | None = None, forward_fallback_text: str | None = None, name_hint: str | None = None, trace_id: str | None = None, ) -> TicketChatResult | None: """为这张工单开一个群(已经有就返回那一个)。 ``intro_text`` 覆盖默认开场白 —— 「转人工」那一条要说清「助手没解决,按某某 类转到你这里」,而求助人自己开的群只需要一句「这个群在讲哪张工单」。 ``forward_feishu_message_id`` 把那条助手回复**原样转发**进来(而不是重新排版 一遍:它带着自己的格式和参考文档链接,重排会把两者都丢掉)。转发失败时贴 ``forward_fallback_text`` —— **群里必须有那段回答**,格式差一点也好过让负责人 去问「他到底看到了什么」。这条规矩留在这个模块里,而不是交给调用方判断 「转发成没成」:转发的失败在这里被吞掉,调用方看不见它。 ``name_hint`` 是群名里那一段给人读的字(转人工用的是问题类别)。没有它就用 工单号 —— 精确,但对人来说不如「权限与账号」好认。 ``members`` 是求助人**之外**要拉进来的人(处理人、路由负责人、被点名的同事)。 只有一个人的群比没有群更糟 —— 员工会对着一个空房间等 —— 所以拉不到第二个人 时**不建群**,返回 ``None``,调用方照旧只留工单。 失败一律返回 ``None`` 并记日志:工单已经在了,这条路上的每一次外部调用都是 加分项。 """ existing = await get_by_ticket(db, ticket.id) reopening: AssistantTicketChat | None = None if existing is not None: if existing.state == CHAT_STATE_OPEN: return TicketChatResult(chat=existing, created=False) # 🔴 群解散过,而这张工单又活了(被重开、或者关掉之后员工回来说还有 # 问题)。**复用那一行**,换上新群的 id —— ``ticket_id`` 上有唯一约束, # 所以插第二行会撞;而一个「一旦解散过就永远不能再有群」的工单,在 # 重开之后只能回到工单页上打字,而那正是这条链路要修的东西。 # # 旧的 ``feishu_chat_id`` 被覆盖:那个房间已经不存在了,留着它只会让 # 后面每一次发送都是一次注定失败的外部调用。 reopening = existing logger.info( "assistant: reopening a room for ticket %s (previous one was %s)", ticket.id, existing.close_reason, ) client = await channel.build_client(db) if client is None: logger.warning("assistant: cannot open a ticket chat without Feishu config") return None lang = resolve_lang(requester) people = [requester, *[m for m in members if m.id != requester.id]] open_ids: list[str] = [] for person in people: email = (person.work_email or "").strip() if not email: continue try: open_ids.append(await client.resolve_open_id_by_email(email)) except Exception as exc: # noqa: BLE001 — 少一个人也许还能开 logger.info( "assistant: no Feishu account for %s: %s", person.id, exc, ) # 去重:同一个人可能既是求助人又被当作成员传进来(不同的 ORM 实例、同一个 # 人),而飞书会把重复的 open_id 当成一次邀请 —— 那样两个人的群其实只有一个人。 open_ids = list(dict.fromkeys(open_ids)) if len(open_ids) < 2: logger.info( "assistant: not opening a chat for ticket %s — only %d member(s)", ticket.id, len(open_ids), ) return None who = requester.display_name or requester.work_email or "" try: chat_id = await client.create_group_chat( name=_pick(_CHAT_NAME, lang).format( number=name_hint or ticket.ticket_number, who=who, ), member_open_ids=open_ids, description=f"Helix ticket {ticket.ticket_number}", ) except Exception as exc: # noqa: BLE001 — 降级路径已经准备好了 logger.warning("assistant: could not open ticket chat: %s", exc) return None # 群已经建出来了 —— 从这里往下的每一步失败都**不能**再报「没有群」。一个 # 两个人都在、却没人提起的孤儿群,比没有群更让人困惑。 if reopening is not None: row = reopening row.feishu_chat_id = chat_id row.state = CHAT_STATE_OPEN row.opened_by_person_id = opened_by.id if opened_by else None row.opened_reason = reason row.last_activity_at = datetime.now(UTC) # 这三样属于**上一个**房间,留着会让运营台把一个刚开的房间显示成 # 「已解散、因为到期」——而升级戳留着会让这个新房间永远升级不了。 row.closed_at = None row.close_reason = None row.card_message_id = None row.escalated_at = None else: row = AssistantTicketChat( ticket_id=ticket.id, feishu_chat_id=chat_id, state=CHAT_STATE_OPEN, opened_by_person_id=opened_by.id if opened_by else None, opened_reason=reason, last_activity_at=datetime.now(UTC), ) db.add(row) try: await db.flush() except IntegrityError: # 两次并发的「开群」都跑到了这里(飞书重投、或者两个人同时点)。唯一约束 # 挡住了第二次;回滚到已经存在的那一行,而不是报错 —— 两个人都该看到 # 同一个群。代价是刚才建的那个群成了孤儿,所以这条路要尽量少走: # ``open_for_ticket`` 的第一件事就是查已有的那一行。 await db.rollback() prior = await get_by_ticket(db, ticket.id) if prior is not None: logger.warning( "assistant: raced another chat for ticket %s; keeping %s", ticket.id, prior.feishu_chat_id, ) return TicketChatResult(chat=prior, created=False) raise await _seed_room( db, chat=row, ticket=ticket, requester=requester, opened_by=opened_by, lang=lang, portal_url=portal_url, intro_text=intro_text, forward_feishu_message_id=forward_feishu_message_id, forward_fallback_text=forward_fallback_text, ) # 只在**真的建出了一个群**时记一行(复用已有房间的那两条路各自返回 # ``created=False``,那里什么都没有新发生)。群一旦建出来就会出现在每个 # 成员的会话列表里 —— 那是不可逆的,而不可逆的事要留痕。 await _audit_room( db, action=ACTION_ROOM_OPENED, chat=row, ticket=ticket, actor_person_id=opened_by.id if opened_by is not None else None, detail={ "reason": reason, "members": [str(m.id) for m in members], }, trace_id=trace_id, ) return TicketChatResult(chat=row, created=True) async def _seed_room( db: AsyncSession, *, chat: AssistantTicketChat, ticket: Any, requester: IdentityPerson, opened_by: IdentityPerson | None, lang: Lang, portal_url: str | None, intro_text: str | None = None, forward_feishu_message_id: str | None = None, forward_fallback_text: str | None = None, ) -> None: """开场白(+ 原样转发的那条回答)+ 工单卡。 整段 best-effort:群本身才是有价值的产物,开场白和卡片是锦上添花。原来这几步 和建群在同一个 try 里,于是开场白一失败就报「没拉到人」—— 而飞书里躺着一个 两个人都在、没人提起的孤儿群。 """ who = ( (opened_by.display_name or opened_by.work_email) if opened_by else (requester.display_name or requester.work_email) ) or "Helix" try: await channel.send_text( db, to=ReplyTarget.chat(chat.feishu_chat_id), text=intro_text or _pick(_OPENED_NOTE, lang).format( who=who, number=ticket.ticket_number, ), ) except Exception as exc: # noqa: BLE001 logger.info("assistant: ticket chat intro failed: %s", exc) forwarded = False if forward_feishu_message_id: # 原样转发,而不是把回答重新排版一遍:它带着自己的格式和参考文档链接。 client = await channel.build_client(db) if client is not None: try: await client.forward_message_to_chat( message_id=forward_feishu_message_id, chat_id=chat.feishu_chat_id, ) forwarded = True except Exception as exc: # noqa: BLE001 — 下面还有贴纯文本那条路 logger.info("assistant: forward into ticket chat failed: %s", exc) if not forwarded and forward_fallback_text: # 转发不成(拿不到 message_id、消息太老、权限变化)时贴纯文本。 # **群里必须有那段回答** —— 格式差一点,好过让负责人去问「他到底看到 # 了什么」。这条判断留在这里,因为转发的失败在这里被吞掉了,调用方 # 看不见它。 await post_note(db, chat=chat, text=forward_fallback_text[:1500]) await post_ticket_card( db, chat=chat, ticket=ticket, requester=requester, assignee=None, lang=lang, portal_url=portal_url, ) async def post_ticket_card( db: AsyncSession, *, chat: AssistantTicketChat, ticket: Any, requester: IdentityPerson | None, assignee: IdentityPerson | None, lang: Lang, portal_url: str | None = None, ) -> None: """发出那张卡,并记下它的 message id。 记不下 message id 不是致命的:卡片还在,只是之后不能原地重画,状态变化退化 成群里的一条文字消息。所以这里绝不因为拿不到 id 而当作发送失败。 """ card = ticket_card.build_ticket_card( ticket_number=ticket.ticket_number, ticket_id=ticket.id, title=ticket.title, status=ticket.status, priority=ticket.priority, requester_name=( (requester.display_name or requester.work_email) if requester else "" ) or "", assignee_name=( (assignee.display_name or assignee.work_email) if assignee else None ), lang=lang, portal_url=portal_url, ) client = await channel.build_client(db) if client is None: return try: sent = await client.send_card_to_chat( chat_id=chat.feishu_chat_id, card=card, idempotency_key=f"ticket-card-{chat.id}", ) except Exception as exc: # noqa: BLE001 — 卡片是加分项 logger.warning("assistant: ticket card delivery failed: %s", exc) return message_id = "" if isinstance(sent, dict): message_id = str(sent.get("message_id") or "") if message_id: chat.card_message_id = message_id[:64] async def refresh_card( db: AsyncSession, *, chat: AssistantTicketChat, ticket: Any, requester: IdentityPerson | None, assignee: IdentityPerson | None, lang: Lang, portal_url: str | None = None, ) -> bool: """把群里那张卡重画成工单**当前**的样子。 重画当前状态、而不是「刚才发生了什么」,是这条链路唯一安全的形状:一次失败 的刷新只是慢一步,绝不会让群里挂着一张描述从未发生过的事的卡。 没有 ``card_message_id``(发卡时没拿到)时补发一张新的 —— 群里有一张过期的 卡,好过有一张永远停在「待受理」的卡。 """ if chat.state != CHAT_STATE_OPEN: return False client = await channel.build_client(db) if client is None: return False card = ticket_card.build_ticket_card( ticket_number=ticket.ticket_number, ticket_id=ticket.id, title=ticket.title, status=ticket.status, priority=ticket.priority, requester_name=( (requester.display_name or requester.work_email) if requester else "" ) or "", assignee_name=( (assignee.display_name or assignee.work_email) if assignee else None ), lang=lang, portal_url=portal_url, ) if not chat.card_message_id: await post_ticket_card( db, chat=chat, ticket=ticket, requester=requester, assignee=assignee, lang=lang, portal_url=portal_url, ) return bool(chat.card_message_id) try: await client.update_card_message( message_id=chat.card_message_id, card=card, ) except Exception as exc: # noqa: BLE001 logger.warning( "assistant: ticket card refresh failed for chat %s: %s", chat.feishu_chat_id, exc, ) return False return True # ── 群里的动作 ────────────────────────────────────────────────────── async def invite( db: AsyncSession, *, chat: AssistantTicketChat, people: list[IdentityPerson], actor: IdentityPerson, lang: Lang = DEFAULT_LANG, ) -> list[IdentityPerson]: """把人拉进这个群。返回真正拉进去的那些。 拉人是**加成员**,不是授权:被拉进来的人能读到这个房间之后的对话,但他能对 这张工单做什么,仍然由他自己的角色和 assignee 决定。 """ if chat.state != CHAT_STATE_OPEN or not people: return [] client = await channel.build_client(db) if client is None: return [] resolved: list[tuple[IdentityPerson, str]] = [] for person in people: email = (person.work_email or "").strip() if not email: continue try: resolved.append((person, await client.resolve_open_id_by_email(email))) except Exception as exc: # noqa: BLE001 logger.info("assistant: cannot invite %s: %s", person.id, exc) if not resolved: return [] try: await client.add_chat_members( chat_id=chat.feishu_chat_id, member_open_ids=[open_id for _p, open_id in resolved], ) except Exception as exc: # noqa: BLE001 logger.warning("assistant: add_chat_members failed: %s", exc) return [] names = "、".join( (p.display_name or p.work_email or "") for p, _ in resolved ) await post_note( db, chat=chat, text=_pick(_INVITED_NOTE, lang).format( who=actor.display_name or actor.work_email or "Helix", name=names, ), ) return [p for p, _ in resolved] async def post_note( db: AsyncSession, *, chat: AssistantTicketChat, text: str, ) -> bool: """群里的一条系统说明(谁做了什么)。""" if chat.state != CHAT_STATE_OPEN or not text.strip(): return False return await channel.send_text( db, to=ReplyTarget.chat(chat.feishu_chat_id), text=text, ) def touch(chat: AssistantTicketChat) -> None: """记一次「这个群还活着」。 保留策略(工单关了 N 天之后解散)和「这个群是不是已经没人管了」都读这一列, 所以每一次群里跟助手说话都要落一次。 """ chat.last_activity_at = datetime.now(UTC) #: 解散前的告别语。**必须有** —— 一个房间凭空消失,屋里的人只会以为出了什么事; #: 而这句话同时是「记录去哪了」的答案。 _FAREWELL: dict[Lang, str] = { "zh": "这张工单({number})已经结了,这个群一会儿就解散。讨论过程已经记在" "工单上,以后想看在 Helix 里搜工单号就行。", "en": "Ticket {number} is closed, so this room is about to be disbanded. " "The discussion is recorded on the ticket — search the number in " "Helix any time.", "ja": "チケット {number} はクローズされたため、このグループは解散します。" "議論の内容はチケットに記録済みです。", } _IDLE_NUDGE: dict[Lang, str] = { "zh": "这个群已经 {days} 天没动静了,而工单 {number} 还开着。还需要什么就" "在这儿说;已经好了的话说一句「解决了 <说明>」我就结案。", "en": "This room has been quiet for {days} days and ticket {number} is " "still open. Say what you need here — or “resolved ” " "and I'll close it.", "ja": "このグループは {days} 日間動きがなく、チケット {number} は未完了です。" "ご要望があればこちらへ、解決済みなら「解決 <対応内容>」とお送りください。", } async def disband( db: AsyncSession, *, chat: AssistantTicketChat, ticket: Any, reason: str, lang: Lang = DEFAULT_LANG, actor_person_id: Any | None = None, trace_id: str | None = None, ) -> bool: """解散这个群。**不可逆:聊天记录随它一起没了。** 所以顺序是刻意的:先发告别语(说清记录去哪了),再删房间,最后才落状态。 ⚠️ **只解散台账上认得的房间。** ``chat`` 来自 ``assistant_ticket_chats``, 那是 Helix 自己开的群;一个仅仅把机器人拉进去的房间永远不会走到这里。 删除失败时**不**把状态标成已关闭:那会让台账说「群没了」而群还在,于是没有 任何一条路径会再去清它。返回 ``False``,下一次扫描重试。 """ client = await channel.build_client(db) if client is None: return False try: await channel.send_text( db, to=ReplyTarget.chat(chat.feishu_chat_id), text=_pick(_FAREWELL, lang).format(number=ticket.ticket_number), ) except Exception as exc: # noqa: BLE001 — 告别语失败不该挡住清理 logger.info("assistant: farewell before disband failed: %s", exc) try: await client.disband_chat(chat_id=chat.feishu_chat_id) except Exception as exc: # noqa: BLE001 logger.warning( "assistant: could not disband chat %s: %s", chat.feishu_chat_id, exc, ) return False mark_closed(chat, reason=reason) # 记录**在删成功之后**:一次失败的解散不是一件发生过的事,而一行说「群被 # 解散了」而群还在的审计,比没有这一行更糟。 await _audit_room( db, action=ACTION_ROOM_DISBANDED, chat=chat, ticket=ticket, actor_person_id=actor_person_id, detail={"reason": reason}, trace_id=trace_id, ) return True async def audit_disband( db: AsyncSession, *, chat: AssistantTicketChat, ticket: Any = None, reason: str, actor_person_id: Any | None = None, trace_id: str | None = None, ) -> None: """给**不走 :func:`disband`** 的那条清理路留的同一行审计。 收尾任务里「工单已经不存在了」的房间直接调飞书删群(没有工单号可念,连 告别语都不一样),但删掉的东西是同一个:一整段聊天记录。少这一行,那类 清理就是唯一一种**无人署名的销毁**。 """ await _audit_room( db, action=ACTION_ROOM_DISBANDED, chat=chat, ticket=ticket, actor_person_id=actor_person_id, detail={"reason": reason}, trace_id=trace_id, ) async def nudge_idle( db: AsyncSession, *, chat: AssistantTicketChat, ticket: Any, days: int, lang: Lang = DEFAULT_LANG, ) -> bool: """一个没人管的房间里问一句「这件事还要不要」。 比自动关单温和得多,而且**它给的是一条出路**:说一句「解决了 …」就结案。 自动关掉一张还开着的工单,会让「已关闭」这个状态不再代表任何东西。 """ return await post_note( db, chat=chat, text=_pick(_IDLE_NUDGE, lang).format( days=days, number=ticket.ticket_number, ), ) def mark_closed(chat: AssistantTicketChat, *, reason: str) -> None: """群不在了(被解散,或机器人被移出)。 往一个不存在的房间发消息会失败,而失败原因读起来像飞书故障 —— 所以这件事 必须落成状态,不能靠下一次调用去发现。 """ chat.state = CHAT_STATE_CLOSED chat.closed_at = datetime.now(UTC) chat.close_reason = reason[:32] __all__ = [ "TicketChatResult", "disband", "nudge_idle", "chat_applink", "get_by_chat", "get_by_ticket", "invite", "mark_closed", "open_for_ticket", "post_note", "post_ticket_card", "refresh_card", "touch", ]