机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,9 +16,12 @@ 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
|
||||
@@ -26,8 +29,11 @@ from agent.redis_coord.client import RedisClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 新群还没有游标时,只回看最近几分钟,避免每轮翻三小时。
|
||||
_NEW_ROOM_LOOKBACK_SEC = 180
|
||||
# 新群还没有游标时,回看窗口必须盖过「存档入库延迟 + 冷群轮转」。
|
||||
# 以前 180 秒:存档晚几分钟到、或本轮预算扫不到该群时,人话永久漏掉,last_seq 也永远建不起来。
|
||||
_NEW_ROOM_LOOKBACK_SEC = 3600
|
||||
# 微盛「请求频率过高」:本轮立刻停,下一轮再扫,避免把活跃群也拖死。
|
||||
_RATE_LIMIT_CODE = 10061
|
||||
|
||||
|
||||
def archive_seat_userids(settings: Optional[Settings] = None) -> list[str]:
|
||||
@@ -146,6 +152,7 @@ def poll_once(
|
||||
page_size=page_size,
|
||||
lookback=lookback,
|
||||
clock=clock,
|
||||
settings=cfg,
|
||||
)
|
||||
if extra:
|
||||
records = list(records) + extra
|
||||
@@ -176,6 +183,7 @@ def poll_once(
|
||||
page_size=page_size,
|
||||
lookback=lookback,
|
||||
clock=clock,
|
||||
settings=cfg,
|
||||
)
|
||||
if extra:
|
||||
records = list(records) + extra
|
||||
@@ -226,9 +234,10 @@ def _poll_by_scan_users(
|
||||
"""
|
||||
按人扫新群。空运群是人自建的,存档席没进群时房间名单里没有它。
|
||||
|
||||
副作用:看到带 @询价小助手 的新房间会登记监听。
|
||||
副作用:看到带 @机器人显示名(含旧名)的新房间会登记监听。
|
||||
"""
|
||||
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())
|
||||
@@ -258,7 +267,7 @@ def _poll_by_scan_users(
|
||||
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:
|
||||
if room and text_mentions_bot(blob):
|
||||
try:
|
||||
watch_collab_room(room, redis=redis)
|
||||
except Exception:
|
||||
@@ -276,19 +285,37 @@ def _poll_by_rooms(
|
||||
page_size: int,
|
||||
lookback: int,
|
||||
clock: Callable[[], float],
|
||||
settings: Optional[Settings] = None,
|
||||
) -> tuple[list[dict[str, Any]], str, int]:
|
||||
"""只拉已登记协同群。应用群不要带存档席 userid,否则管家给空页。"""
|
||||
rooms = list_watched_rooms(redis=redis)
|
||||
if not rooms:
|
||||
"""
|
||||
只拉已登记协同群。应用群不要带存档席 userid,否则管家给空页。
|
||||
|
||||
热群每轮必扫;冷群按游标轮转。遇微盛限频或时间预算用尽则本轮停,下一轮续扫。
|
||||
禁止把每轮冷群压得太少:销售在冷群发「解散群」时,要等很多轮才会被听见。
|
||||
"""
|
||||
cfg = settings or get_settings()
|
||||
all_rooms = list_watched_rooms(redis=redis)
|
||||
if not all_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),
|
||||
)
|
||||
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
|
||||
@@ -307,7 +334,13 @@ def _poll_by_rooms(
|
||||
)
|
||||
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
|
||||
max_seq = last
|
||||
for rec in rows:
|
||||
seq = _record_seq(rec)
|
||||
@@ -318,4 +351,17 @@ def _poll_by_rooms(
|
||||
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
|
||||
|
||||
@@ -52,12 +52,98 @@ def mark_room_hot(chat_id: str, *, ttl_sec: float = HOT_WINDOW_SEC) -> None:
|
||||
|
||||
def has_hot_room() -> bool:
|
||||
"""是否还有热窗内的协同群。"""
|
||||
return bool(list_hot_rooms())
|
||||
|
||||
|
||||
def list_hot_rooms() -> list[str]:
|
||||
"""当前热窗内的协同群(刚建群或刚有人说话)。"""
|
||||
now = time.time()
|
||||
with _hot_lock:
|
||||
stale = [k for k, until in _hot_until.items() if until <= now]
|
||||
for k in stale:
|
||||
_hot_until.pop(k, None)
|
||||
return any(until > now for until in _hot_until.values())
|
||||
return [k for k, until in _hot_until.items() if until > now]
|
||||
|
||||
|
||||
def is_room_hot(chat_id: str) -> bool:
|
||||
"""这群是否还在热窗内。"""
|
||||
raw = (chat_id or "").strip()
|
||||
if not raw:
|
||||
return False
|
||||
now = time.time()
|
||||
with _hot_lock:
|
||||
until = _hot_until.get(raw) or 0.0
|
||||
if until <= now:
|
||||
_hot_until.pop(raw, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def plan_rooms_for_poll(
|
||||
rooms: list[str],
|
||||
*,
|
||||
max_cold: int = 120,
|
||||
redis: Optional[RedisClient] = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
本轮要扫的房间:热群全部优先,冷群按游标轮转。
|
||||
|
||||
注意:这里只读游标、不推进。真正扫过多少冷群后,由 advance_poll_cursor 推进,
|
||||
避免限频/超时提前停时把没扫到的群直接跳过。
|
||||
"""
|
||||
uniq: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in rooms:
|
||||
room = (raw or "").strip()
|
||||
if not room or room in seen:
|
||||
continue
|
||||
seen.add(room)
|
||||
uniq.append(room)
|
||||
hot = [r for r in uniq if is_room_hot(r)]
|
||||
hot_set = set(hot)
|
||||
cold = [r for r in uniq if r not in hot_set]
|
||||
budget = max(0, int(max_cold))
|
||||
if not cold or budget <= 0:
|
||||
return hot
|
||||
start = load_poll_cursor(redis=redis) % len(cold)
|
||||
rotated = cold[start:] + cold[:start]
|
||||
take = rotated[:budget]
|
||||
# 热群在前,保证刚发过话/刚建的群本轮一定扫到。
|
||||
return hot + [r for r in take if r not in hot_set]
|
||||
|
||||
|
||||
def load_poll_cursor(*, redis: Optional[RedisClient] = None) -> int:
|
||||
"""冷群轮转游标。"""
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return 0
|
||||
raw_cur = client.raw.get(client.key(WSHOTO_LAST_SEQ, "poll_room_cursor"))
|
||||
if isinstance(raw_cur, bytes):
|
||||
raw_cur = raw_cur.decode("utf-8")
|
||||
try:
|
||||
return int(raw_cur or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def advance_poll_cursor(
|
||||
cold_polled: int,
|
||||
*,
|
||||
redis: Optional[RedisClient] = None,
|
||||
) -> None:
|
||||
"""
|
||||
按本轮实际扫过的冷群数推进游标。
|
||||
|
||||
限频或时间预算提前停时,只推进已扫到的,下轮从停住的地方继续。
|
||||
"""
|
||||
n = max(0, int(cold_polled))
|
||||
if n <= 0:
|
||||
return
|
||||
client = _client(redis)
|
||||
if client is None:
|
||||
return
|
||||
cur = load_poll_cursor(redis=client)
|
||||
client.raw.set(client.key(WSHOTO_LAST_SEQ, "poll_room_cursor"), str(int(cur + n)))
|
||||
|
||||
|
||||
def _client(redis: Optional[RedisClient] = None) -> Optional[RedisClient]:
|
||||
|
||||
Reference in New Issue
Block a user