"""SSE 发送结果校验总线。 发送文本后,发送方注册一个 (to_wxid, content) 等待;MessageStreamer 广播新消息时调用 notify,匹配到等待即 resolve 为 True。超时自动清理 (默认 2.5s),避免内存泄漏。 设计要点: - 完全异步、单线程事件循环(asyncio.Future) - 精确匹配 talker == to_wxid 且 is_sender=True(自己发出的消息) 且 content 包含发送内容(微信可能加前缀/后缀) - 多发送并发时,每个 (to_wxid, content) 独立 Future,互不干扰 - 防泄漏:register 后无论是否被 notify,超时都会清理并 resolve False - 不依赖 DB,无熔断器,无雪崩风险 """ from __future__ import annotations import asyncio import logging import time from typing import Optional logger = logging.getLogger("woc-bridge") # 默认等待超时(秒):SSE 是推送机制,比 DB 轮询快, # 但 MessageStreamer 有 1s 轮询间隔 + DB mtime 检测延迟, # 2.5s 足够覆盖正常场景。 _DEFAULT_TIMEOUT_SEC = 2.5 class SSEVerifyBus: """SSE 发送结果校验总线。 线程安全说明:asyncio.Future 与 dict 操作都在同一事件循环中, 无需加锁。notify 由 MessageStreamer._broadcast 调用(同一 loop), register/wait 由 SendTextFlow 调用(同一 loop)。 """ def __init__(self, default_timeout_sec: float = _DEFAULT_TIMEOUT_SEC) -> None: self._default_timeout = default_timeout_sec # key: (to_wxid, content_normalized) -> list[(future, expire_at)] # 用 list 支持同 to_wxid + 同 content 的并发发送(极少见,但需支持) self._pending: dict[tuple[str, str], list[tuple[asyncio.Future, float]]] = {} def _normalize(self, content: str) -> str: """规范化内容用于匹配:去除首尾空白。 微信消息内容与发送内容可能有微小差异(如尾随换行), 用 strip 后的子串匹配更鲁棒。 """ return (content or "").strip() def register( self, to_wxid: str, content: str, timeout_sec: Optional[float] = None, ) -> asyncio.Future: """注册一个发送结果等待。 Args: to_wxid: 目标联系人 wxid content: 发送的消息内容 timeout_sec: 超时秒数,None 用默认值 Returns: asyncio.Future[bool]:True 表示收到匹配的 SSE 事件, False 表示超时未收到 """ loop = asyncio.get_running_loop() future: asyncio.Future = loop.create_future() timeout = timeout_sec if timeout_sec is not None else self._default_timeout expire_at = time.monotonic() + timeout key = (to_wxid, self._normalize(content)) self._pending.setdefault(key, []).append((future, expire_at)) # 超时自动清理:超时后 resolve False 并移除条目 def _on_timeout() -> None: if not future.done(): future.set_result(False) self._cleanup_done(key) loop.call_later(timeout, _on_timeout) return future async def wait(self, future: asyncio.Future) -> bool: """等待 register 返回的 future 完成。 单独提供 wait 方法便于在 async 上下文中 await。 """ return await future def notify( self, talker: str, content: str, is_sender: bool, ) -> int: """消息广播时调用,匹配 pending 等待并 resolve。 一条 SSE 消息只匹配一个等待(FIFO),避免同 (to_wxid, content) 的 并发发送被同一条消息全部 resolve。后续相同等待需等下一条 SSE 消息。 Args: talker: 消息的 talker 字段(会话对方 wxid) content: 消息内容 is_sender: 是否是自己发出的消息 Returns: 匹配到的等待数量(0 或 1) """ if not is_sender: return 0 normalized = self._normalize(content) # 精确匹配 talker,但 content 用子串匹配(微信可能加前缀) # 遍历所有 pending key,找 talker 匹配且 content 包含目标内容的 matched = 0 keys_to_check = [ k for k in self._pending.keys() if k[0] == talker ] for key in keys_to_check: if matched > 0: break # 一条消息只匹配一个等待 _, target_content = key # target_content 是发送时规范化的内容 # 检查消息内容是否包含目标内容(微信群消息可能加发送者前缀) if target_content and target_content in normalized: waiters = self._pending.get(key, []) if not waiters: self._pending.pop(key, None) continue # FIFO:只 resolve 第一个未完成的等待 future, expire_at = waiters[0] if not future.done(): future.set_result(True) matched = 1 # 移除已 resolve 的条目 remaining = waiters[1:] if remaining: self._pending[key] = remaining else: self._pending.pop(key, None) return matched def _cleanup_done(self, key: tuple[str, str]) -> None: """清理已完成的等待条目(超时或已 resolve)。""" waiters = self._pending.get(key) if waiters is None: return remaining = [(f, t) for f, t in waiters if not f.done()] if remaining: self._pending[key] = remaining else: self._pending.pop(key, None) def pending_count(self) -> int: """当前等待中的校验数量(供监控/调试)。""" return sum(len(v) for v in self._pending.values()) def clear(self) -> None: """清除所有等待(resolve False)。供 lifespan 停止时调用。""" for key, waiters in list(self._pending.items()): for future, _ in waiters: if not future.done(): future.set_result(False) self._pending.clear()