WechatOnCloud/bridge/woc_bridge/messaging/verify_bus.py
Kris 054c4676aa refactor: 重构发送校验逻辑,移除DB轮询与熔断器,改用SSE校验
主要变更:
1. 新增SSEVerifyBus实现基于推送的发送结果校验,替代原DB轮询方案
2. 移除FlowOrchestrator中的DB熔断器,避免雪崩风险
3. 重构SendTextFlow,改用SSE校验流程,删除原DB校验相关代码
4. 新增RoiSpec与ROI搜索支持,优化UI元素匹配精度
5. 更新所有分辨率配置文件,添加ROI配置与调整搜索框坐标
6. 新增WOC_DISABLE_OPENCV环境变量开关,支持纯几何定位模式
7. 清理FlowContext中废弃的before_msg_id字段
2026-07-17 23:50:03 +08:00

169 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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