""" 微盛群消息一轮:按「询价机器人」席位拉新话,写入 inbox。 本文件职责:换票查询、归一、入 inbox、推进测服 last_seq。 Worker 独立线程轮询,不占 HTTP 槽、不堵回调。入队后由 inbox 线程分发。 禁止:堵回调;禁止推现网官方 SDK 游标;禁止把存档席当发言人。 """ from __future__ import annotations import logging import time from typing import Any, Callable, Optional, Sequence from agent.channel.queue import get_message_store from agent.channel.wshoto.client import WshotoError, WshotoOpenClient, wshoto_ready from agent.channel.wshoto.parse import archive_record_to_inbound from agent.channel.wshoto.rooms import ( list_watched_rooms, load_last_seq, mark_room_hot, save_last_seq, ) from agent.config import Settings, get_settings from agent.redis_coord.client import RedisClient logger = logging.getLogger(__name__) # 新群还没有游标时,只回看最近几分钟,避免每轮翻三小时。 _NEW_ROOM_LOOKBACK_SEC = 180 def archive_seat_userids(settings: Optional[Settings] = None) -> list[str]: """ 会话存档账号 userid。 测服群里的「询价机器人」就是这一席;默认读 WECOM_ARCHIVE_SEAT_USER_IDS(AI001)。 """ cfg = settings or get_settings() raw = str(getattr(cfg, "wecom_archive_seat_user_ids", "") or "").strip() ids: list[str] = [] for part in raw.replace(";", ",").split(","): uid = part.strip() if uid and uid not in ids: ids.append(uid) return ids def _record_seq(record: dict[str, Any]) -> int: try: return int(record.get("seq") or 0) except (TypeError, ValueError): return 0 def ingest_records( records: Sequence[dict[str, Any]], *, seat_userids: Sequence[str], min_seq: int = 0, store: Any = None, ) -> tuple[int, int]: """ 把一页管家记录写入 inbox。 返回:(入队成功数, 跳过数)。无 sender / 存档席 / 重复 message_id 都算跳过。 """ book = store if store is not None else get_message_store() enqueued = 0 skipped = 0 for rec in records: if _record_seq(rec) <= min_seq: skipped += 1 continue msg = archive_record_to_inbound(rec, seat_userids=seat_userids) if msg is None: skipped += 1 continue ok, reason = book.enqueue_inbound(msg) if ok: enqueued += 1 mark_room_hot(msg.chat_id) logger.info( "存档入队 sender=%s chat=%s type=%s reason=%s", msg.sender_id, msg.chat_id, msg.msg_type, reason, ) else: skipped += 1 return enqueued, skipped def _needs_target(exc: WshotoError) -> bool: text = (exc.msg or "").lower() return "target_id" in text or "targetid" in text or "房间" in (exc.msg or "") def poll_once( *, client: Optional[WshotoOpenClient] = None, settings: Optional[Settings] = None, redis: Optional[RedisClient] = None, store: Any = None, now: Optional[Callable[[], float]] = None, ) -> dict[str, Any]: """ 拉一页并存入 inbox。 已有 last_seq:按 seq 增量查。没有:只看最近若干小时,避免从 0 翻全公司历史。 已登记协同群按房间号拉(不要带存档席 userid,应用群否则给空页)。 还没有群时才按席位试拉。 """ cfg = settings or get_settings() if not wshoto_ready(cfg): return {"ok": False, "reason": "not_configured"} seats = archive_seat_userids(cfg) if not seats: logger.warning("微盛轮询:未配置 WECOM_ARCHIVE_SEAT_USER_IDS,只能靠显示名丢掉询价机器人自己的话") api = client or WshotoOpenClient(cfg, redis=redis) lookback = max(60, int(getattr(cfg, "wshoto_bootstrap_lookback_sec", 10800) or 10800)) page_size = int(getattr(cfg, "wshoto_page_size", 50) or 50) clock = now or time.time rooms = list_watched_rooms(redis=redis) scan_ids = [ x.strip() for x in str(getattr(cfg, "wshoto_scan_user_ids", "") or "").replace(";", ",").split(",") if x.strip() ] extra: list[dict[str, Any]] = [] if scan_ids: extra = _poll_by_scan_users( api, userids=scan_ids, redis=redis, page_size=page_size, lookback=lookback, clock=clock, ) if rooms: # 应用群必须带房间号、不要带存档席 userid,否则管家返回空页。 records, mode, last = _poll_by_rooms( api, redis=redis, page_size=page_size, lookback=lookback, clock=clock, ) if extra: records = list(records) + extra else: seat = seats[0] if seats else "" last = load_last_seq(f"seat:{seat or 'default'}", redis=redis) time_start = 0 time_end = 0 if last <= 0: time_end = int(clock()) time_start = time_end - lookback try: records = api.list_group_messages( userid=seat, last_seq=last, page_size=page_size, msg_time_start=time_start, msg_time_end=time_end, ) mode = "seat" except WshotoError as exc: if not _needs_target(exc): logger.warning("微盛按席拉取失败 code=%s msg=%s", exc.code, exc.msg) return {"ok": False, "reason": "list_failed", "code": exc.code} records, mode, last = _poll_by_rooms( api, redis=redis, page_size=page_size, lookback=lookback, clock=clock, ) if extra: records = list(records) + extra mode = "rooms" # 按房间拉时各群自己的 last_seq 已经过滤过,这里不要再用席游标误丢。 ingest_min = 0 if mode == "rooms" else last enqueued, skipped = ingest_records( records, seat_userids=seats, min_seq=ingest_min, store=store, ) max_seq = last for rec in records: seq = _record_seq(rec) if seq > max_seq: max_seq = seq if max_seq > last and not rooms: save_last_seq(f"seat:{(seats[0] if seats else 'default')}", max_seq, redis=redis) logger.info( "微盛轮询 mode=%s fetched=%s enqueued=%s skipped=%s last_seq=%s→%s", mode, len(records), enqueued, skipped, last, max_seq, ) return { "ok": True, "mode": mode, "fetched": len(records), "enqueued": enqueued, "skipped": skipped, "last_seq": max_seq, } def _poll_by_scan_users( api: WshotoOpenClient, *, userids: Sequence[str], redis: Optional[RedisClient], page_size: int, lookback: int, clock: Callable[[], float], ) -> list[dict[str, Any]]: """ 按人扫新群。空运群是人自建的,存档席没进群时房间名单里没有它。 副作用:看到带 @询价小助手 的新房间会登记监听。 """ from agent.channel.wshoto.rooms import watch_collab_room merged: list[dict[str, Any]] = [] now = int(clock()) for uid in userids: scope = f"scan:{uid}" last = load_last_seq(scope, redis=redis) time_start = 0 time_end = 0 if last <= 0: time_end = now time_start = time_end - min(lookback, 8 * 3600) try: rows = api.list_group_messages( userid=uid, last_seq=last, page_size=page_size, msg_time_start=time_start, msg_time_end=time_end, ) except WshotoError as exc: logger.warning("微盛按人扫群失败 userid=%s code=%s", uid, exc.code) continue max_seq = last for rec in rows: seq = _record_seq(rec) if seq > max_seq: max_seq = seq room = str(rec.get("room_id") or rec.get("roomid") or "").strip() blob = str(rec.get("content") or "") if room and "询价小助手" in blob: try: watch_collab_room(room, redis=redis) except Exception: logger.exception("扫到空运群后登记监听失败 chat=%s", room) if max_seq > last: save_last_seq(scope, max_seq, redis=redis) merged.extend(rows) return merged def _poll_by_rooms( api: WshotoOpenClient, *, redis: Optional[RedisClient], page_size: int, lookback: int, clock: Callable[[], float], ) -> tuple[list[dict[str, Any]], str, int]: """只拉已登记协同群。应用群不要带存档席 userid,否则管家给空页。""" rooms = list_watched_rooms(redis=redis) if not rooms: logger.warning("微盛按房间拉取:还没有登记过的协同群") return [], "rooms", 0 rooms = sorted( rooms, key=lambda r: (0 if load_last_seq(f"room:{r}", redis=redis) <= 0 else 1, r), ) merged: list[dict[str, Any]] = [] floor = 0 for room in rooms: scope = f"room:{room}" last = load_last_seq(scope, redis=redis) time_start = 0 time_end = 0 if last <= 0: time_end = int(clock()) time_start = time_end - min(lookback, _NEW_ROOM_LOOKBACK_SEC) try: rows = api.list_group_messages( userid="", target_id=room, last_seq=last, page_size=page_size, msg_time_start=time_start, msg_time_end=time_end, ) except WshotoError as exc: logger.warning("微盛房间拉取失败 chat=%s code=%s msg=%s", room, exc.code, exc.msg) continue max_seq = last for rec in rows: seq = _record_seq(rec) if seq > max_seq: max_seq = seq if max_seq > last: save_last_seq(scope, max_seq, redis=redis) if last > floor: floor = last merged.extend(rows) return merged, "rooms", floor