Files
inquiry_robot/inquiry-agent/agent/channel/wshoto/poll.py
T

247 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
微盛群消息一轮:按「询价机器人」席位拉新话,写入 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)
if rooms:
# 应用群必须带房间号、不要带存档席 userid,否则管家返回空页。
records, mode, last = _poll_by_rooms(
api,
redis=redis,
page_size=page_size,
lookback=lookback,
clock=clock,
)
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,
)
# 按房间拉时各群自己的 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_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