Files
inquiry_robot/inquiry-agent/agent/jobs/collab_watch_reconcile.py
T

353 lines
12 KiB
Python

"""
协同群监听对账:主账绑群 ↔ 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