""" 测服自己的微盛监听名单与 last_seq。 本文件职责:把协同群 chat_id、按席/按房间的查询游标写到 REDIS_KEY_PREFIX 下。 禁止:写现网 ytd:prod: 前缀;禁止把游标当六态。 线程:可被建群线程与 Worker 轮询同时调用,只做单 key 读写。 """ from __future__ import annotations import logging import threading import time from typing import Optional from agent.redis_coord import get_redis_runtime from agent.redis_coord.client import RedisClient from agent.redis_coord.keys import WSHOTO_LAST_SEQ, WSHOTO_ROOM_TICKET, WSHOTO_ROOMS logger = logging.getLogger(__name__) # 建群后或刚听到群话:立刻唤醒轮询,并在热窗内更勤地扫。 _poll_wake = threading.Event() _hot_until: dict[str, float] = {} _hot_lock = threading.Lock() HOT_WINDOW_SEC = 90.0 def wake_wshoto_poll() -> None: """打断轮询等待。建群线程可调;真正拉消息仍在 Worker 轮询线程。""" _poll_wake.set() def wait_wshoto_poll(timeout: float) -> bool: """等下一轮或被唤醒。返回是否被踢醒。""" fired = _poll_wake.wait(timeout=max(0.1, float(timeout))) if fired: _poll_wake.clear() return fired def mark_room_hot(chat_id: str, *, ttl_sec: float = HOT_WINDOW_SEC) -> None: """这群接下来几十秒更勤扫:刚建群或刚有人说话。""" raw = (chat_id or "").strip() if not raw: return until = time.time() + max(5.0, float(ttl_sec)) with _hot_lock: _hot_until[raw] = until wake_wshoto_poll() 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 [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]: if redis is not None: return redis try: return get_redis_runtime().client except Exception: logger.exception("微盛监听名单:Redis 不可用") return None def watch_collab_room( chat_id: str, *, work_order_no: str = "", redis: Optional[RedisClient] = None, ) -> None: """ 建群成功后登记 chat_id,供按房间补拉。 可同时记下房间→工单号,主账按群查找暂时不可用时也能对上单。 副作用:改 Redis。失败只打日志,不挡销售侧建群。 """ raw = (chat_id or "").strip() if not raw: return client = _client(redis) if client is None: return key = client.key(WSHOTO_ROOMS) current = client.raw.get(key) or "" if isinstance(current, bytes): current = current.decode("utf-8") rooms = {x.strip() for x in str(current).split(",") if x.strip()} rooms.add(raw) client.raw.set(key, ",".join(sorted(rooms))) no = (work_order_no or "").strip() if no: client.raw.set(client.key(WSHOTO_ROOM_TICKET, raw), no) logger.info("存档监听已登记群数=%s", len(rooms)) mark_room_hot(raw) def ticket_no_for_room(chat_id: str, *, redis: Optional[RedisClient] = None) -> str: """用协同群 chat_id 找回工单号(测服 Redis 短索引,不是六态)。""" raw = (chat_id or "").strip() if not raw: return "" client = _client(redis) if client is None: return "" val = client.raw.get(client.key(WSHOTO_ROOM_TICKET, raw)) if val is None: return "" if isinstance(val, bytes): val = val.decode("utf-8") return str(val).strip() def unwatch_collab_room(chat_id: str, *, redis: Optional[RedisClient] = None) -> None: """协同结束后去掉监听,避免解散群还被当成活群补拉。""" raw = (chat_id or "").strip() if not raw: return client = _client(redis) if client is None: return key = client.key(WSHOTO_ROOMS) current = client.raw.get(key) or "" if isinstance(current, bytes): current = current.decode("utf-8") rooms = {x.strip() for x in str(current).split(",") if x.strip() and x.strip() != raw} client.raw.set(key, ",".join(sorted(rooms))) client.raw.delete(client.key(WSHOTO_ROOM_TICKET, raw)) def list_watched_rooms(*, redis: Optional[RedisClient] = None) -> list[str]: """当前要按房间补拉的协同群。""" client = _client(redis) if client is None: return [] current = client.raw.get(client.key(WSHOTO_ROOMS)) or "" if isinstance(current, bytes): current = current.decode("utf-8") return [x.strip() for x in str(current).split(",") if x.strip()] def load_last_seq(scope: str, *, redis: Optional[RedisClient] = None) -> int: """ 读取本前缀下的 last_seq。 scope:seat:AI001 或 room:wrxxx。没有记录返回 0(调用方改走时间窗,避免从 0 翻全量历史)。 """ client = _client(redis) if client is None: return 0 val = client.raw.get(client.key(WSHOTO_LAST_SEQ, scope)) if val is None: return 0 try: return int(val) except (TypeError, ValueError): return 0 def save_last_seq(scope: str, seq: int, *, redis: Optional[RedisClient] = None) -> None: """推进测服自己的查询游标。seq 必须单调不减。""" if seq <= 0: return client = _client(redis) if client is None: return prev = load_last_seq(scope, redis=client) if seq < prev: return client.raw.set(client.key(WSHOTO_LAST_SEQ, scope), str(int(seq)))