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