机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-19 09:41:59 +08:00
co-authored by Cursor
parent 277cdc1c28
commit 2b528758b4
119 changed files with 6760 additions and 3516 deletions
+25
View File
@@ -113,6 +113,7 @@ class WorkerRuntime:
threading.Thread(target=self._wshoto_poll_loop, name="wshoto-poll", daemon=True),
threading.Thread(target=self._deal_followup_loop, name="deal-followup", daemon=True),
threading.Thread(target=self._event_exception_loop, name="event-exception", daemon=True),
threading.Thread(target=self._collab_watch_reconcile_loop, name="collab-watch", daemon=True),
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
]
for t in self._threads:
@@ -307,6 +308,30 @@ class WorkerRuntime:
logger.exception("事件异常巡检失败")
self._stop.wait(60.0)
def _collab_watch_reconcile_loop(self) -> None:
"""
HTTP 槽对账协同群监听。独立线程,不占回调、不与 soffice 串队。
缺监听补挂;主账已结束或监听孤儿则清人摘监听,避免「解散群」没人听见。
"""
from agent.jobs.collab_watch_reconcile import reconcile_collab_watch
interval = float(
getattr(self.settings, "collab_watch_reconcile_interval_sec", 900.0) or 900.0
)
interval = min(3600.0, max(120.0, interval))
# 启动后先等一截,避开 Worker 冷启动打满企微。
self._stop.wait(min(90.0, interval))
while not self._stop.is_set():
try:
self.http_jobs.run(
lambda: reconcile_collab_watch(settings=self.settings),
label="collab-watch",
)
except Exception:
logger.exception("协同群监听对账失败")
self._stop.wait(interval)
def _pdf_loop(self) -> None:
"""仅 LibreOffice 单槽消费 PDF Stream。"""
if not self.redis:
@@ -0,0 +1,352 @@
"""
协同群监听对账:主账绑群 ↔ Redis 监听名单 ↔ 企微成员。
本文件职责:Worker HTTP 槽定时跑。缺监听则补挂;已结束/无主账则清人并摘监听。
禁止:回调线程调用;占 LibreOffice 槽;改六态;一把大锁串行所有工单。
禁止:listOpenCollab 失败/空列表时把仍有工单映射的活群当孤儿清掉。
"""
from __future__ import annotations
import logging
import time
from typing import Any, Optional
from agent.config import Settings, get_settings
logger = logging.getLogger(__name__)
# 单轮最多清多少个群,避免企微限频拖死 Worker。
_MAX_DISMISS_PER_RUN = 20
# 双 Worker 互斥:同一前缀下 10 分钟内只跑一轮。
_LOCK_TTL_SEC = 600
def reconcile_collab_watch(
*,
ledger: Any = None,
settings: Optional[Settings] = None,
dismiss: bool = True,
) -> dict[str, int]:
"""
对账一轮。返回计数:rewatched / dismissed / skipped / errors。
主账 listOpenCollab 不可用时,仍可按监听名单侧清理(无单/已结束)。
"""
cfg = settings or get_settings()
stats = {
"rewatched": 0,
"dismissed": 0,
"skipped": 0,
"empty_unwatched": 0,
"errors": 0,
"listed": 0,
"watched": 0,
}
if str(getattr(cfg, "ledger_backend", "http") or "http").lower() == "memory":
return stats
if not _try_lock(cfg):
stats["skipped"] = 1
return stats
book = ledger
if book is None:
from agent.ledger.http_ledger import HttpLedger
book = HttpLedger()
from agent.channel.wshoto.rooms import (
list_watched_rooms,
ticket_no_for_room,
unwatch_collab_room,
watch_collab_room,
)
watched = list(list_watched_rooms())
watched_set = set(watched)
stats["watched"] = len(watched)
open_rows: list[dict[str, Any]] = []
list_ok = False
lister = getattr(book, "list_open_collab", None)
if callable(lister):
try:
raw = lister()
# None = 接口失败(与空列表区分);空列表在「仍有监听」时也不能当孤儿清扫依据。
if raw is None:
list_ok = False
open_rows = []
stats["errors"] += 1
logger.error("列出开放协同群失败(返回 None),本轮不做孤儿清群")
else:
open_rows = list(raw or [])
list_ok = True
except Exception:
logger.exception("列出开放协同群失败")
stats["errors"] += 1
open_rows = []
list_ok = False
stats["listed"] = len(open_rows)
# listed=0 且仍有监听:多半是接口挂了或返回形态不对,禁止走孤儿清人。
orphan_dismiss_ok = bool(dismiss) and list_ok and (
stats["listed"] > 0 or stats["watched"] == 0
)
open_by_chat: dict[str, dict[str, Any]] = {}
for row in open_rows:
if not isinstance(row, dict):
continue
chat = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
if not chat:
continue
open_by_chat[chat] = row
ended = _ended(row)
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
if ended:
continue
if chat not in watched_set:
try:
watch_collab_room(chat, work_order_no=no)
stats["rewatched"] += 1
logger.info("对账补挂监听 wo=%s chat=%s", no or "-", chat)
except Exception:
logger.exception("补挂监听失败 chat=%s", chat)
stats["errors"] += 1
# 待清:主账已结束(不论是否在监听)+ 监听孤儿(监听有、主账无)
client = _group_client()
dismiss_left = _MAX_DISMISS_PER_RUN if dismiss else 0
pending: list[tuple[str, str, list[str]]] = [] # chat, wo, members
for chat, row in open_by_chat.items():
if not _ended(row):
continue
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
members = [str(x) for x in (row.get("collab_member_ids") or row.get("memberIds") or []) if x]
pending.append((chat, no, members))
for chat in list(watched):
row = open_by_chat.get(chat)
if row is None:
finder = getattr(book, "find_by_collab_chat", None)
if callable(finder):
try:
row = finder(chat_id=chat)
except Exception:
row = None
if isinstance(row, dict) and (row.get("work_order_no") or row.get("workOrderNo") or row.get("found")):
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
if _ended(row):
# listOpen 未覆盖到的已结束单:补进待清,避免只摘监听不发确认。
if chat not in open_by_chat:
members = [
str(x)
for x in (row.get("collab_member_ids") or row.get("memberIds") or [])
if x
]
pending.append((chat, no, members))
continue
# 活单:确保房间→工单映射在
if no and not ticket_no_for_room(chat):
try:
watch_collab_room(chat, work_order_no=no)
stats["rewatched"] += 1
except Exception:
stats["errors"] += 1
continue
# 监听有、主账 list/byChat 对不上:先用 Redis 工单号回查,活单绝不当地孤儿清。
no = ticket_no_for_room(chat) or ""
live = _lookup_live_ticket(book, work_order_no=no) if no else None
if live is not None:
if _ended(live):
members = [
str(x)
for x in (live.get("collab_member_ids") or live.get("memberIds") or [])
if x
]
pending.append((chat, no, members))
else:
stats["skipped"] += 1
logger.warning(
"对账跳过疑似孤儿(Redis 工单仍存活)wo=%s chat=%s list_ok=%s listed=%s",
no,
chat,
list_ok,
stats["listed"],
)
continue
if not orphan_dismiss_ok:
stats["skipped"] += 1
continue
pending.append((chat, no, []))
seen_chat: set[str] = set()
for chat, no, members in pending:
if chat in seen_chat:
continue
seen_chat.add(chat)
try:
# 已空只摘监听,不占清人配额,避免历史结束单堵死后面仍有人的群。
owner_now, users_now = _room_members(client, chat_id=chat, members=members)
extras_now = [u for u in users_now if u and u != owner_now]
if not extras_now:
unwatch_collab_room(chat)
stats["empty_unwatched"] += 1
continue
if dismiss_left <= 0:
stats["skipped"] += 1
continue
_dismiss_room(client, chat_id=chat, members=members, work_order_no=no)
unwatch_collab_room(chat)
stats["dismissed"] += 1
dismiss_left -= 1
logger.info("对账清群 wo=%s chat=%s", no or "-", chat)
except Exception:
logger.exception("对账清群失败 chat=%s", chat)
stats["errors"] += 1
return stats
def _ended(row: dict[str, Any]) -> bool:
if bool(row.get("collab_ended") or row.get("collabEnded")):
return True
facts = row.get("collab_facts") or row.get("collabFacts") or {}
if isinstance(facts, dict):
flag = str(facts.get("__ended") or "").strip().lower()
if flag in {"1", "true", "yes"}:
return True
return False
def _lookup_live_ticket(book: Any, *, work_order_no: str) -> Optional[dict[str, Any]]:
"""
用 Redis 里的工单号回查主账。查到则返回可判 ended 的 dict;查不到返回 None。
防止 listOpenCollab/byCollabChat 短暂失败时,把仍存活的协同群当孤儿清掉。
"""
no = (work_order_no or "").strip()
if not no or book is None:
return None
getter = getattr(book, "get_ticket", None)
if not callable(getter):
return None
try:
ticket = getter(work_order_no=no)
except Exception:
logger.exception("对账按工单回查失败 wo=%s", no)
return None
if ticket is None:
return None
if isinstance(ticket, dict):
row = dict(ticket)
if not (row.get("work_order_no") or row.get("workOrderNo")):
row["work_order_no"] = no
return row
# MemoryTicket / 其它对象:尽量投影成 dict
row = {
"work_order_no": str(getattr(ticket, "work_order_no", None) or no),
"collab_ended": bool(getattr(ticket, "collab_ended", False)),
"collab_chat_id": str(getattr(ticket, "collab_chat_id", None) or ""),
"collab_member_ids": list(getattr(ticket, "collab_member_ids", None) or []),
}
facts = getattr(ticket, "collab_facts", None) or getattr(ticket, "facts", None)
if isinstance(facts, dict):
row["collab_facts"] = facts
return row
def _try_lock(settings: Settings) -> bool:
from agent.redis_coord import get_redis_runtime
from agent.redis_coord.idempotency import try_acquire
try:
runtime = get_redis_runtime(settings)
client = getattr(runtime, "client", None)
if client is None:
return True
return bool(
try_acquire(
client,
scope="collab-watch-reconcile",
idempotency_key="run",
ttl_seconds=_LOCK_TTL_SEC,
)
)
except Exception:
logger.exception("对账锁获取失败,本轮仍继续")
return True
def _group_client() -> Any:
from agent.policy.sea_group_ops import group_client_of
from agent.policy.sea_text_flow import get_sea_text_flow
return group_client_of(get_sea_text_flow())
def _room_members(client: Any, *, chat_id: str, members: list[str]) -> tuple[str, list[str]]:
"""
读企微实时成员;仅在接口失败/无 userlist 字段时退回主账成员。
注意:userlist=[] 是「已空」,不能用 `or members`(空列表在 Python 为假),
否则会把主账旧成员当成还在群里,白白占满每轮清人配额。
"""
info: dict[str, Any] = {}
try:
info = client._appchat_info(chat_id=chat_id) or {}
except Exception:
info = {}
raw = info.get("userlist") if isinstance(info, dict) else None
if raw is None:
users = [str(u) for u in (members or []) if u]
else:
users = [str(u) for u in (raw or []) if u]
owner = str((info or {}).get("owner") or "")
return owner, users
def _room_already_empty(client: Any, *, chat_id: str, members: list[str]) -> bool:
"""
群里已无其他人(空群或只剩群主)则视为已解散完成。
这类单不占每轮清人配额,只摘监听即可。
"""
owner, users = _room_members(client, chat_id=chat_id, members=members)
extras = [u for u in users if u and u != owner]
return not extras
def _dismiss_room(
client: Any,
*,
chat_id: str,
members: list[str],
work_order_no: str,
) -> bool:
"""
清人。返回 True=执行过移人。
成员列表以企微实时 userlist 为准;主账成员可能过期。
"""
owner, users = _room_members(client, chat_id=chat_id, members=members)
extras = [u for u in users if u and u != owner]
if not extras:
return False
try:
from agent.policy import inquiry_copy as copy
text = copy.group_dissolved(work_order_no=work_order_no or "-")
send = getattr(client, "send_group", None)
if callable(send):
send(chat_id=chat_id, content=text)
except Exception:
logger.exception("对账清群前确认消息失败 chat=%s", chat_id)
dismiss = getattr(client, "dismiss_group", None)
if callable(dismiss):
dismiss(chat_id=chat_id, userids=users)
return True
return False
@@ -7,8 +7,11 @@
from __future__ import annotations
import json
import logging
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Optional
from agent.config import Settings, get_settings
@@ -90,10 +93,9 @@ def poll_event_exceptions(
test_prefix = False
lookup = timeout_lookup or getattr(book, "get_timeout_by_code", None)
ready = notify_app_flag(notify_ready)
rows = [r for r in (lister() or []) if isinstance(r, dict)]
sent = 0
for row in lister() or []:
if not isinstance(row, dict):
continue
for row in rows:
try:
if _handle_one(
row,
@@ -194,7 +196,8 @@ def _handle_one(
if skip_reason:
logger.info("事件异常跳过 wo=%s kind=%s reason=%s", no, kind, skip_reason)
return False
if workday_elapsed_hours(started, now) < hours:
elapsed = workday_elapsed_hours(started, now)
if elapsed < hours:
return False
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
sales = _ticket_sales(row, watchers)
@@ -206,6 +209,7 @@ def _handle_one(
names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()]
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
where = conversation_of(wait_kind=kind, collab_chat_id=chat_id)
app_targets = pick_app_targets(people)
# 群里用 @。销售超时即使发到私聊,也写成 @销售姓名,不写「您」或「相关人员」。
mention = where == "group" or kind in _SALES_WAIT_KINDS
text = copy.event_exception_session_text(
@@ -248,6 +252,8 @@ def _handle_one(
test_prefix=test_prefix,
enqueue=enqueue or _default_enqueue,
)
else:
pass
recorder = getattr(book, "record_event_exception", None)
if callable(recorder):
recorder(work_order_no=no, event_exception=exception_type(kind))
+41
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from agent.jobs.libreoffice import LibreOfficeConverter, LibreOfficeResult
@@ -20,6 +21,8 @@ from agent.redis_coord.stream_queue import StreamQueue
logger = logging.getLogger(__name__)
@dataclass
class PdfEnqueueResult:
accepted: bool
@@ -160,6 +163,8 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None
or {},
},
)
# PDF 出站同时登记主账附件,后台列表/详情才能看到。
_register_pdf_attachment(wo, filename, result.pdf_path, touser=touser)
if wo:
try:
from agent.policy.air_text_flow import get_air_text_flow
@@ -176,3 +181,39 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None
logger.exception("PDF 出文件后未能切到成交跟进 inquiry=%s", wo)
except Exception:
logger.exception("PDF 出站入队失败 inquiry=%s", wo)
def _register_pdf_attachment(
work_order_no: str,
filename: str,
pdf_path: str,
*,
touser: str = "",
) -> None:
"""
PDF 转换成功后把文件字节登记到主账附件表。
失败只打日志,不挡企微发文件。LibreOffice 槽内调用,短 HTTP。
"""
wo = (work_order_no or "").strip()
path = Path(str(pdf_path or ""))
if not wo or not path.is_file():
return
try:
from agent.ledger.http_ledger import HttpLedger
raw = path.read_bytes()
if not raw:
return
name = (filename or "").strip() or f"{wo}_quote.pdf"
if not name.lower().endswith(".pdf"):
name = f"{name}.pdf"
out = HttpLedger().attach_file_bytes(
work_order_no=wo,
file_name=name,
file_bytes=raw,
content_type="application/pdf",
sender_id=touser or "",
)
except Exception:
logger.exception("PDF 附件登记失败 inquiry=%s", wo)