落地海运协同群:测服靠会话存档听群话,报价单能带上协同字段。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
微盛群消息一轮:按「询价机器人」席位拉新话,写入 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
|
||||
Reference in New Issue
Block a user