2026-07-07 21:52:11 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 企微IT智能服务台 — 阶段5 自动化 进度推送
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 说明:负责把自动化处置过程实时推送给前端:
|
|
|
|
|
|
# 1. 专用 WS 通道 /ws/automation/{session_id}(坐席工作台 + 员工 H5 均可连)
|
|
|
|
|
|
# 2. 兜底推送:同时向坐席(/ws/{agent_id})和员工(/ws/h5/{employee_id})推送,
|
|
|
|
|
|
# 保证未连专用 WS 时也能收到事件。
|
|
|
|
|
|
# 3. 静默关单调度:处置成功后 N 分钟员工无异议则自动关单。
|
|
|
|
|
|
#
|
|
|
|
|
|
# 事件名统一以 automation. 前缀(见 app.constants)。
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from typing import Any, Dict, Optional, Set
|
|
|
|
|
|
|
|
|
|
|
|
from app.constants import (
|
|
|
|
|
|
AUTOMATION_SILENT_CLOSE_TTL,
|
|
|
|
|
|
AUTOMATION_WS_ACTION_REQUIRED,
|
|
|
|
|
|
AUTOMATION_WS_ERROR,
|
2026-07-11 23:13:10 +08:00
|
|
|
|
AUTOMATION_WS_INFO_CORRECTED,
|
|
|
|
|
|
AUTOMATION_WS_INFO_SUPPLEMENTED,
|
|
|
|
|
|
AUTOMATION_WS_PAUSED,
|
2026-07-07 21:52:11 +08:00
|
|
|
|
AUTOMATION_WS_PROGRESS,
|
|
|
|
|
|
AUTOMATION_WS_RESOLVED,
|
2026-07-11 23:13:10 +08:00
|
|
|
|
AUTOMATION_WS_RESUMED,
|
2026-07-07 21:52:11 +08:00
|
|
|
|
AUTOMATION_WS_TAKEOVER,
|
2026-07-11 23:13:10 +08:00
|
|
|
|
AUTOMATION_WS_TIMEOUT_CLOSED,
|
2026-07-07 21:52:11 +08:00
|
|
|
|
)
|
|
|
|
|
|
from app.services.ws_manager import manager as ws_manager
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
# 会话级专用 WS 连接注册表:session_id -> {websocket, ...}
|
|
|
|
|
|
_automation_ws: Dict[str, Set[Any]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
# 会话参与方(用于兜底推送):session_id -> {"agent_id":..,"employee_id":..}
|
|
|
|
|
|
_session_parties: Dict[str, Dict[str, Optional[str]]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
# 静默关单任务:session_id -> asyncio.Task
|
|
|
|
|
|
_silent_close_tasks: Dict[str, asyncio.Task] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_ws(session_id: str, websocket: Any) -> None:
|
|
|
|
|
|
"""注册专用 WS 连接。"""
|
|
|
|
|
|
_automation_ws.setdefault(session_id, set()).add(websocket)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def unregister_ws(session_id: str, websocket: Any) -> None:
|
|
|
|
|
|
"""注销专用 WS 连接。"""
|
|
|
|
|
|
conns = _automation_ws.get(session_id)
|
|
|
|
|
|
if conns:
|
|
|
|
|
|
conns.discard(websocket)
|
|
|
|
|
|
if not conns:
|
|
|
|
|
|
_automation_ws.pop(session_id, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_parties(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
agent_id: Optional[str] = None,
|
|
|
|
|
|
employee_id: Optional[str] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""记录会话参与方,用于兜底推送。"""
|
|
|
|
|
|
parties = _session_parties.setdefault(
|
|
|
|
|
|
session_id, {"agent_id": None, "employee_id": None}
|
|
|
|
|
|
)
|
|
|
|
|
|
if agent_id is not None:
|
|
|
|
|
|
parties["agent_id"] = agent_id
|
|
|
|
|
|
if employee_id is not None:
|
|
|
|
|
|
parties["employee_id"] = employee_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_message(event_type: str, session_id: str, data: Any) -> Dict[str, Any]:
|
|
|
|
|
|
"""构造统一 WS 消息信封。"""
|
|
|
|
|
|
return {"type": event_type, "session_id": session_id, "data": data or {}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _send_to_automation_ws(session_id: str, message: Dict[str, Any]) -> None:
|
|
|
|
|
|
"""向专用 WS 连接推送(并清理失效连接)。"""
|
|
|
|
|
|
conns = list(_automation_ws.get(session_id, set()))
|
|
|
|
|
|
for ws in conns:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await ws.send_json(message)
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
unregister_ws(session_id, ws)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _publish(event_type: str, session_id: str, data: Any) -> None:
|
|
|
|
|
|
"""统一推送:专用 WS + 坐席/员工兜底 WS。"""
|
|
|
|
|
|
message = _build_message(event_type, session_id, data)
|
|
|
|
|
|
await _send_to_automation_ws(session_id, message)
|
|
|
|
|
|
|
|
|
|
|
|
parties = _session_parties.get(session_id, {})
|
|
|
|
|
|
# 兜底推送给坐席
|
|
|
|
|
|
if parties.get("agent_id"):
|
|
|
|
|
|
await ws_manager.send_to_agent(parties["agent_id"], message)
|
|
|
|
|
|
# 兜底推送给员工
|
|
|
|
|
|
if parties.get("employee_id"):
|
|
|
|
|
|
await ws_manager.send_to_employee(parties["employee_id"], message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def publish_progress(
|
|
|
|
|
|
session_id: str, step: str, message: str, action_id: Optional[str] = None
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""推送进度事件。"""
|
|
|
|
|
|
await _publish(
|
|
|
|
|
|
AUTOMATION_WS_PROGRESS,
|
|
|
|
|
|
session_id,
|
|
|
|
|
|
{"step": step, "message": message, "action_id": action_id},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def publish_action_required(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
action: Any,
|
|
|
|
|
|
ticket: Any,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""推送需要审批/确认事件(坐席审批或员工 H5 确认)。"""
|
|
|
|
|
|
await _publish(
|
|
|
|
|
|
AUTOMATION_WS_ACTION_REQUIRED,
|
|
|
|
|
|
session_id,
|
|
|
|
|
|
{
|
|
|
|
|
|
"action": {
|
|
|
|
|
|
"id": action.id,
|
|
|
|
|
|
"action_type": action.action_type,
|
|
|
|
|
|
"title": action.title,
|
|
|
|
|
|
"description": action.description,
|
|
|
|
|
|
"risk_level": action.risk_level,
|
|
|
|
|
|
"payload": action.payload,
|
|
|
|
|
|
},
|
|
|
|
|
|
"ticket": {
|
|
|
|
|
|
"id": ticket.id,
|
|
|
|
|
|
"channel": ticket.channel,
|
|
|
|
|
|
"status": ticket.status,
|
|
|
|
|
|
"reason": ticket.reason,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def publish_resolved(session_id: str, summary: str) -> None:
|
|
|
|
|
|
"""推送处置成功事件。"""
|
|
|
|
|
|
await _publish(AUTOMATION_WS_RESOLVED, session_id, {"summary": summary})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def publish_takeover(session_id: str, reason: str) -> None:
|
|
|
|
|
|
"""推送转人工事件。"""
|
|
|
|
|
|
await _publish(AUTOMATION_WS_TAKEOVER, session_id, {"reason": reason})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def publish_error(session_id: str, code: int, message: str) -> None:
|
|
|
|
|
|
"""推送异常事件。"""
|
|
|
|
|
|
await _publish(AUTOMATION_WS_ERROR, session_id, {"code": code, "message": message})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 23:13:10 +08:00
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 复杂场景重构第一阶段 — 新增 WS 事件推送
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
async def publish_paused(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
title: str,
|
|
|
|
|
|
paused_at: str,
|
|
|
|
|
|
resume_hint: str = "",
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""推送会话暂停事件。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
session_id: 会话ID
|
|
|
|
|
|
title: 会话标题
|
|
|
|
|
|
paused_at: 暂停时间(ISO 字符串)
|
|
|
|
|
|
resume_hint: 恢复提示文案
|
|
|
|
|
|
"""
|
|
|
|
|
|
await _publish(
|
|
|
|
|
|
AUTOMATION_WS_PAUSED,
|
|
|
|
|
|
session_id,
|
|
|
|
|
|
{
|
|
|
|
|
|
"session_id": session_id,
|
|
|
|
|
|
"title": title,
|
|
|
|
|
|
"paused_at": paused_at,
|
|
|
|
|
|
"resume_hint": resume_hint,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def publish_resumed(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
title: str,
|
|
|
|
|
|
resumed_at: str,
|
|
|
|
|
|
current_step: str = "",
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""推送会话恢复事件。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
session_id: 会话ID
|
|
|
|
|
|
title: 会话标题
|
|
|
|
|
|
resumed_at: 恢复时间(ISO 字符串)
|
|
|
|
|
|
current_step: 当前步骤描述
|
|
|
|
|
|
"""
|
|
|
|
|
|
await _publish(
|
|
|
|
|
|
AUTOMATION_WS_RESUMED,
|
|
|
|
|
|
session_id,
|
|
|
|
|
|
{
|
|
|
|
|
|
"session_id": session_id,
|
|
|
|
|
|
"title": title,
|
|
|
|
|
|
"resumed_at": resumed_at,
|
|
|
|
|
|
"current_step": current_step,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def publish_timeout_closed(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
closed_at: str,
|
|
|
|
|
|
reason: str = "",
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""推送暂停超时关闭事件。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
session_id: 会话ID
|
|
|
|
|
|
closed_at: 关闭时间(ISO 字符串)
|
|
|
|
|
|
reason: 关闭原因
|
|
|
|
|
|
"""
|
|
|
|
|
|
await _publish(
|
|
|
|
|
|
AUTOMATION_WS_TIMEOUT_CLOSED,
|
|
|
|
|
|
session_id,
|
|
|
|
|
|
{
|
|
|
|
|
|
"session_id": session_id,
|
|
|
|
|
|
"closed_at": closed_at,
|
|
|
|
|
|
"reason": reason,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def publish_info_corrected(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
field: str,
|
|
|
|
|
|
old_value: str,
|
|
|
|
|
|
new_value: str,
|
|
|
|
|
|
version: int,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""推送信息更正事件。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
session_id: 会话ID
|
|
|
|
|
|
field: 更正的字段名
|
|
|
|
|
|
old_value: 旧值
|
|
|
|
|
|
new_value: 新值
|
|
|
|
|
|
version: 更正后的版本号
|
|
|
|
|
|
"""
|
|
|
|
|
|
await _publish(
|
|
|
|
|
|
AUTOMATION_WS_INFO_CORRECTED,
|
|
|
|
|
|
session_id,
|
|
|
|
|
|
{
|
|
|
|
|
|
"session_id": session_id,
|
|
|
|
|
|
"field": field,
|
|
|
|
|
|
"old_value": old_value,
|
|
|
|
|
|
"new_value": new_value,
|
|
|
|
|
|
"version": version,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def publish_info_supplemented(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
field: str,
|
|
|
|
|
|
supplement_value: str,
|
|
|
|
|
|
new_value: str,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""推送信息补充事件。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
session_id: 会话ID
|
|
|
|
|
|
field: 补充的字段名
|
|
|
|
|
|
supplement_value: 本次补充的值
|
|
|
|
|
|
new_value: 补充后的完整值
|
|
|
|
|
|
"""
|
|
|
|
|
|
await _publish(
|
|
|
|
|
|
AUTOMATION_WS_INFO_SUPPLEMENTED,
|
|
|
|
|
|
session_id,
|
|
|
|
|
|
{
|
|
|
|
|
|
"session_id": session_id,
|
|
|
|
|
|
"field": field,
|
|
|
|
|
|
"supplement_value": supplement_value,
|
|
|
|
|
|
"new_value": new_value,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 21:52:11 +08:00
|
|
|
|
def schedule_silent_close(
|
|
|
|
|
|
session_id: str, ttl: int = AUTOMATION_SILENT_CLOSE_TTL, on_expire=None
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""调度静默关单:ttl 秒后若会话仍为 resolved,则自动关单。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
session_id: 会话ID
|
|
|
|
|
|
ttl: 静默期秒数(默认 600 = 10 分钟)
|
|
|
|
|
|
on_expire: 到期回调 coroutine(通常 = AutoSessionService.auto_close)
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 取消已有的同名任务,避免重复调度
|
|
|
|
|
|
existing = _silent_close_tasks.get(session_id)
|
|
|
|
|
|
if existing is not None and not existing.done():
|
|
|
|
|
|
existing.cancel()
|
|
|
|
|
|
|
|
|
|
|
|
if on_expire is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
async def _wait_and_close() -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await asyncio.sleep(ttl)
|
|
|
|
|
|
await on_expire(session_id)
|
|
|
|
|
|
except asyncio.CancelledError: # 被新的调度取消
|
|
|
|
|
|
pass
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
logger.warning(f"静默关单回调异常 session={session_id}: {e}")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
_silent_close_tasks.pop(session_id, None)
|
|
|
|
|
|
|
|
|
|
|
|
_silent_close_tasks[session_id] = asyncio.create_task(_wait_and_close())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cancel_silent_close(session_id: str) -> None:
|
|
|
|
|
|
"""取消静默关单调度(如会话已被接管/关单)。"""
|
|
|
|
|
|
task = _silent_close_tasks.pop(session_id, None)
|
|
|
|
|
|
if task is not None and not task.done():
|
|
|
|
|
|
task.cancel()
|