WechatOnCloud/bridge/woc_bridge/messaging/verify_bus.py

169 lines
6.2 KiB
Python
Raw Normal View History

"""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()