机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user