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

456 lines
15 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 (
advance_poll_cursor,
is_room_hot,
list_watched_rooms,
load_last_seq,
mark_room_hot,
plan_rooms_for_poll,
save_last_seq,
)
from agent.config import Settings, get_settings
from agent.redis_coord.client import RedisClient
logger = logging.getLogger(__name__)
# 新群还没有游标时,回看窗口必须盖过「存档入库延迟 + 冷群轮转」。
# 以前 180 秒:存档晚几分钟到、或本轮预算扫不到该群时,人话永久漏掉,last_seq 也永远建不起来。
_NEW_ROOM_LOOKBACK_SEC = 3600
# 微盛「请求频率过高」:本轮立刻停,下一轮再扫,避免把活跃群也拖死。
_RATE_LIMIT_CODE = 10061
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,
settings=cfg,
)
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,
settings=cfg,
)
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
from agent.policy.bot_name import text_mentions_bot
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 text_mentions_bot(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 _merge_records_by_seq(
*batches: Sequence[dict[str, Any]],
min_seq: int = 0,
) -> list[dict[str, Any]]:
"""按 seq 去重合并多页;同 seq 后写覆盖。只保留 seq > min_seq。"""
by_seq: dict[int, dict[str, Any]] = {}
for batch in batches:
for rec in batch or ():
seq = _record_seq(rec)
if seq <= min_seq:
continue
by_seq[seq] = dict(rec)
return [by_seq[s] for s in sorted(by_seq)]
def _backfill_room_seq_gap(
api: WshotoOpenClient,
*,
room: str,
last_seq: int,
rows: list[dict[str, Any]],
page_size: int,
lookback: int,
clock: Callable[[], float],
) -> list[dict[str, Any]]:
"""
增量结果跳号时,用时间窗补中间漏掉的消息。
现网:游标 9892,管家增量只回撤回 9899,把「解散群」9898 永久跨过。
若本页最小 seq > last_seq+1,再打一枪时间窗,按 seq 合并后返回。
补洞失败则原样返回(调用方仍按本页 max 推进,故补洞要尽量成功)。
"""
if last_seq <= 0 or not rows:
return rows
page_seqs = [_record_seq(r) for r in rows if _record_seq(r) > 0]
if not page_seqs:
return rows
min_page = min(page_seqs)
if min_page <= last_seq + 1:
return rows
now_i = int(clock())
window = min(max(60, int(lookback or 0)), _NEW_ROOM_LOOKBACK_SEC)
logger.warning(
"微盛房间增量跳号 chat=%s last=%s min_page=%s,时间窗补洞 window=%ss",
room,
last_seq,
min_page,
window,
)
try:
fill = api.list_group_messages(
userid="",
target_id=room,
last_seq=0,
page_size=page_size,
msg_time_start=now_i - window,
msg_time_end=now_i,
)
except WshotoError as exc:
logger.warning(
"微盛房间补洞失败 chat=%s code=%s msg=%s",
room,
exc.code,
exc.msg,
)
return rows
merged = _merge_records_by_seq(rows, fill or [], min_seq=last_seq)
if len(merged) > len(rows):
logger.info(
"微盛房间补洞成功 chat=%s added=%s total=%s",
room,
len(merged) - len(rows),
len(merged),
)
return merged
def _poll_by_rooms(
api: WshotoOpenClient,
*,
redis: Optional[RedisClient],
page_size: int,
lookback: int,
clock: Callable[[], float],
settings: Optional[Settings] = None,
) -> tuple[list[dict[str, Any]], str, int]:
"""
只拉已登记协同群。应用群不要带存档席 userid,否则管家给空页。
热群每轮必扫;冷群按游标轮转。遇微盛限频或时间预算用尽则本轮停,下一轮续扫。
禁止把每轮冷群压得太少:销售在冷群发「解散群」时,要等很多轮才会被听见。
"""
cfg = settings or get_settings()
all_rooms = list_watched_rooms(redis=redis)
if not all_rooms:
logger.warning("微盛按房间拉取:还没有登记过的协同群")
return [], "rooms", 0
max_cold = int(getattr(cfg, "wshoto_rooms_per_poll", 120) or 120)
max_cold = min(200, max(1, max_cold))
gap_ms = int(getattr(cfg, "wshoto_room_gap_ms", 100) or 100)
gap_ms = min(1000, max(0, gap_ms))
budget_sec = float(getattr(cfg, "wshoto_poll_budget_sec", 20.0) or 20.0)
budget_sec = min(60.0, max(5.0, budget_sec))
rooms = plan_rooms_for_poll(all_rooms, max_cold=max_cold, redis=redis)
merged: list[dict[str, Any]] = []
floor = 0
rate_limited = False
budget_hit = False
polled = 0
cold_polled = 0
t0 = time.time()
for room in rooms:
if (time.time() - t0) >= budget_sec:
budget_hit = True
break
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)
if int(exc.code) == _RATE_LIMIT_CODE:
rate_limited = True
break
continue
polled += 1
if not is_room_hot(room):
cold_polled += 1
# 增量若跳号:管家有时先吐出撤回等高 seq,游标一步跨过中间正文
# (现网:解散群 seq=9898 被撤回 9899 推过去后永远听不见)。跳号则时间窗补洞。
rows = _backfill_room_seq_gap(
api,
room=room,
last_seq=last,
rows=list(rows or []),
page_size=page_size,
lookback=lookback,
clock=clock,
)
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)
if gap_ms > 0:
time.sleep(gap_ms / 1000.0)
# 只推进实际扫过的冷群,限频/超时停住的下轮接着扫。
advance_poll_cursor(cold_polled, redis=redis)
if rate_limited or budget_hit:
logger.warning(
"微盛房间拉取提前结束 rate=%s budget=%s watched=%s planned=%s polled=%s",
rate_limited,
budget_hit,
len(all_rooms),
len(rooms),
polled,
)
return merged, "rooms", floor