353 lines
12 KiB
Python
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
|