diff --git a/bridge/woc_bridge/app.py b/bridge/woc_bridge/app.py index c505842..3d802db 100644 --- a/bridge/woc_bridge/app.py +++ b/bridge/woc_bridge/app.py @@ -149,6 +149,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await asyncio.wait_for(_state.message_streamer.stop(), timeout=5.0) except (asyncio.TimeoutError, Exception): logger.warning("[lifespan] message_streamer.stop() 超时或异常,强制继续") + # 清理 SSE 发送结果校验总线:resolve 所有 pending 等待为 False + if _state.verify_bus is not None: + try: + _state.verify_bus.clear() + except Exception: + logger.warning("[lifespan] verify_bus.clear() 异常,强制继续") # 停止群发后台协程(LIFO:先于 send_queue 停止,避免新任务继续入队) if _state.batch_worker is not None: try: @@ -380,11 +386,19 @@ def _init_state(cfg: BridgeConfig) -> None: max_queue_size=cfg.max_queue_size, ) + # SSE 发送结果校验总线:send_text 发送后注册等待, + # MessageStreamer 广播消息时 notify 匹配 talker + content。 + # 替代 DB 轮询校验,避免熔断器雪崩。 + from woc_bridge.messaging.verify_bus import SSEVerifyBus + _state.verify_bus = SSEVerifyBus(default_timeout_sec=2.5) + # 消息流推送器:内部轮询 DB + SSE 广播(lifespan 中 start/stop) + # 注入 verify_bus:广播 messages 事件时 notify 匹配 pending 的发送等待 _state.message_streamer = MessageStreamer( db_reader=_state.db_reader, resolve_db_state=_resolve_db_state_tuple, extract_key=lambda: _auto_extract_with_lock(force=True), + verify_bus=_state.verify_bus, ) # 读取 WOC_DB_KEY 环境变量,注入 DbReader @@ -461,6 +475,7 @@ def _init_state(cfg: BridgeConfig) -> None: db_reader=_state.db_reader, config=cfg, send_file_flow=send_file_flow, + verify_bus=_state.verify_bus, ) # 构造 Capabilities diff --git a/bridge/woc_bridge/config.py b/bridge/woc_bridge/config.py index 4f33565..1bacb8c 100644 --- a/bridge/woc_bridge/config.py +++ b/bridge/woc_bridge/config.py @@ -241,6 +241,8 @@ class AppState: self.send_queue: SendQueue | None = None self.qr_capture: QrCapture | None = None self.message_streamer: MessageStreamer | None = None + # SSE 发送结果校验总线(send_text 校验,避免 DB 轮询雪崩) + self.verify_bus: Optional[Any] = None # SSEVerifyBus self.start_time: float = 0.0 # DB 解密密钥缓存(env / api / auto_extract / file 注入) self.key_cache = KeyCache() diff --git a/bridge/woc_bridge/messaging/streamer.py b/bridge/woc_bridge/messaging/streamer.py index 191b5da..411d85c 100644 --- a/bridge/woc_bridge/messaging/streamer.py +++ b/bridge/woc_bridge/messaging/streamer.py @@ -97,6 +97,7 @@ class MessageStreamer: db_reader: "DbReader", resolve_db_state: DbStateResolver, extract_key: Optional[Callable[[], Awaitable[Optional[dict[str, str]]]]] = None, + verify_bus: Optional["SSEVerifyBus"] = None, ) -> None: """初始化推送器。 @@ -107,10 +108,14 @@ class MessageStreamer: extract_key: 异步回调,强制重新提取 DB 密钥。 get_messages_since 遇到 DB_ENCRYPTED 时调用,成功后重试一次。 None 时不做重试(仅记日志) + verify_bus: SSE 发送结果校验总线(可选)。 + 注入后,广播 messages 事件时会调用 verify_bus.notify, + 匹配 pending 的发送结果等待,实现发送确认。 """ self._db_reader = db_reader self._resolve_db_state = resolve_db_state self._extract_key = extract_key + self._verify_bus = verify_bus # value 为订阅时间戳(monotonic),用于在超限时剔除最早订阅者 self._subscribers: dict[asyncio.Queue[StreamEvent], float] = {} self._global_cursor: int = 0 @@ -219,10 +224,25 @@ class MessageStreamer: 队列满时丢弃最旧事件再放入,避免单客户端消费慢拖垮全局。 日志在此处统一打印一次,避免在每订阅者生成器循环内重复打印。 + + messages 事件广播前先调用 verify_bus.notify,匹配 pending 的发送 + 结果等待。这样 SendTextFlow._wait_verify 能在消息广播时立即收到 + 通知,无需等待 SSE 订阅者消费事件。 """ # sync 事件只发给单个订阅者(在 subscribe 内直接 put),不走广播 if event.event != "sync": self._log_broadcast_once(event) + # messages 事件:先 notify verify_bus,再广播给订阅者 + if event.event == "messages" and self._verify_bus is not None: + for msg in event.data.get("messages", []) or []: + try: + self._verify_bus.notify( + talker=msg.get("talker", ""), + content=msg.get("content", ""), + is_sender=bool(msg.get("is_sender", False)), + ) + except Exception as exc: + logger.warning("verify_bus.notify exception: %s", exc) for q in list(self._subscribers): try: q.put_nowait(event) diff --git a/bridge/woc_bridge/messaging/verify_bus.py b/bridge/woc_bridge/messaging/verify_bus.py new file mode 100644 index 0000000..296f2b5 --- /dev/null +++ b/bridge/woc_bridge/messaging/verify_bus.py @@ -0,0 +1,168 @@ +"""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() diff --git a/bridge/woc_bridge/routes/send.py b/bridge/woc_bridge/routes/send.py index a763f6e..85a206d 100644 --- a/bridge/woc_bridge/routes/send.py +++ b/bridge/woc_bridge/routes/send.py @@ -175,8 +175,8 @@ async def _verify_sent_to_talker( 新消息存在。 Note: - P3 阶段该校验将迁移到 SendTextFlow._verify_sent,本阶段先在 - routes/send.py 修复防串号问题。 + P3 阶段该校验已迁移到 SendTextFlow._wait_verify(基于 SSE 广播), + 本函数仅 legacy 路径使用。 """ if db_reader is None: return True @@ -384,7 +384,7 @@ async def _send_text_via_legacy(req: SendTextRequest) -> SendResponse: async def _send_text_via_flow(req: SendTextRequest) -> SendResponse: - """新架构发送:经 FlowOrchestrator 编排(幂等/熔断/会话缓存/DB 校验)。""" + """新架构发送:经 FlowOrchestrator 编排(幂等/会话缓存/SSE 校验)。""" orchestrator = _state.orchestrator if orchestrator is None: raise BridgeError( diff --git a/bridge/woc_bridge/ui/actions.py b/bridge/woc_bridge/ui/actions.py index 33d8e44..e9c8a20 100644 --- a/bridge/woc_bridge/ui/actions.py +++ b/bridge/woc_bridge/ui/actions.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio import logging +import os import time from typing import Optional @@ -24,6 +25,14 @@ from woc_bridge.ui.locators.registry import LocatorRegistry logger = logging.getLogger("woc-bridge") +# 纯坐标模式开关:WOC_DISABLE_OPENCV=1 时跳过 OpenCV 模板匹配, +# find_element 直接走几何兜底(by_geom 比例坐标),不截图不匹配。 +# 适用于模板图像不可靠的场景,靠窗口几何比例定位元素。 +# 启动时读取一次,运行时不再重复读环境变量。 +_DISABLE_OPENCV = os.environ.get("WOC_DISABLE_OPENCV", "").strip() in ( + "1", "true", "True", "TRUE", "yes", +) + class ElementNotFoundError(Exception): """元素未找到(图像匹配失败且 require_image=True,或几何规格缺失)。 @@ -90,6 +99,17 @@ class Actions: """ sel = self.locator.get(kind) + # 纯坐标模式:WOC_DISABLE_OPENCV=1 时直接走几何兜底, + # 跳过截图与 OpenCV 模板匹配,按 Selector.by_geom 比例坐标定位。 + # 返回 strategy="geom",confidence=1.0。 + if _DISABLE_OPENCV: + logger.debug( + "[action] opencv disabled (WOC_DISABLE_OPENCV=1), " + "use geom directly kind=%s", + kind, + ) + return self._geom_find(sel, win_geom) + # 图像路径(P2 启用,P1 阶段 opencv=None 直接跳过) if ( sel.by_image @@ -100,7 +120,10 @@ class Actions: if shot is None: shot = await self.backend.screenshot() self._set_cached_screenshot(shot) - roi = (win_geom.x, win_geom.y, win_geom.width, win_geom.height) + # 计算 OpenCV 搜索 ROI:优先用 Selector.roi(相对窗口的比例矩形), + # 无则回退到全窗口。ROI 限制可防止模板误匹配到窗口其他相似元素 + # (如 input_box 模板错误时匹配到窗口中部,参见 false-positive 事件)。 + roi = self._compute_roi(sel, win_geom) try: result = await self.locator.opencv.find_template( shot, @@ -257,6 +280,42 @@ class Actions: # ------------------------------------------------------------------ # 几何兜底 # ------------------------------------------------------------------ + def _compute_roi( + self, selector: Selector, win_geom: WindowGeometry + ) -> tuple[int, int, int, int]: + """计算 OpenCV 搜索 ROI(绝对像素坐标)。 + + 优先使用 Selector.roi(相对窗口的比例矩形),无则回退到全窗口。 + 输出格式为 (x, y, w, h),与 OpenCVBackend.find_template 的 roi 参数一致。 + 边界由 opencv.py 内部裁剪保护,这里只做基础 clamp 防负值。 + + Args: + selector: 元素选择器(可能含 roi) + win_geom: 当前窗口几何 + + Returns: + (x, y, w, h) 绝对像素 ROI + """ + if selector.roi is None: + return (win_geom.x, win_geom.y, win_geom.width, win_geom.height) + r = selector.roi + abs_x = win_geom.x + int(win_geom.width * r.x_ratio) + abs_y = win_geom.y + int(win_geom.height * r.y_ratio) + abs_w = int(win_geom.width * r.w_ratio) + abs_h = int(win_geom.height * r.h_ratio) + # 基础保护:防止负值或零尺寸 + abs_w = max(abs_w, 1) + abs_h = max(abs_h, 1) + logger.debug( + "[action] compute_roi kind=%s win=(%d,%d,%dx%d) " + "roi_ratio=(%.3f,%.3f,%.3f,%.3f) → abs=(%d,%d,%dx%d)", + selector.kind, + win_geom.x, win_geom.y, win_geom.width, win_geom.height, + r.x_ratio, r.y_ratio, r.w_ratio, r.h_ratio, + abs_x, abs_y, abs_w, abs_h, + ) + return (abs_x, abs_y, abs_w, abs_h) + def _geom_find( self, selector: Selector, win_geom: WindowGeometry ) -> ElementHandle: diff --git a/bridge/woc_bridge/ui/flow.py b/bridge/woc_bridge/ui/flow.py index f517e9f..bac88d6 100644 --- a/bridge/woc_bridge/ui/flow.py +++ b/bridge/woc_bridge/ui/flow.py @@ -56,7 +56,6 @@ class FlowContext: display_name: str = "" client_request_id: str = "" win_geom: Optional[WindowGeometry] = None - before_msg_id: int = 0 local_send_id: str = "" verified: bool = False image_confidence: float = 0.0 diff --git a/bridge/woc_bridge/ui/flows/send_text.py b/bridge/woc_bridge/ui/flows/send_text.py index 29a8b04..5545f9e 100644 --- a/bridge/woc_bridge/ui/flows/send_text.py +++ b/bridge/woc_bridge/ui/flows/send_text.py @@ -5,6 +5,9 @@ focus_input → type_content → send → cleanup。 会话缓存命中时跳过前 4 步,仅做 _verify_session_still_open。 异常时调 _reset_to_idle 恢复 UI 状态。 + +发送结果校验:通过 SSEVerifyBus 注册等待,MessageStreamer 广播消息时 +notify 匹配 talker + content。无 DB 轮询,无熔断器,无雪崩风险。 """ from __future__ import annotations @@ -13,7 +16,7 @@ import asyncio import logging import random import time -from typing import Awaitable, Callable, Optional +from typing import Optional from woc_bridge.ui.backends.base import WindowGeometry from woc_bridge.ui.errors import FlowError, PermanentError @@ -30,25 +33,28 @@ logger = logging.getLogger("woc-bridge") # 微信窗口标题 _WECHAT_WINDOW_TITLE = "微信" -# DB 校验参数 -_DB_VERIFY_TIMEOUT_SEC = 3.0 -_DB_VERIFY_INTERVAL_SEC = 0.2 - class SendTextFlow(Flow): """发送文本消息 Flow。""" - def __init__(self, actions, db_reader=None, session_cache=None) -> None: + def __init__(self, actions, db_reader=None, session_cache=None, + verify_bus=None) -> None: + """初始化 SendTextFlow。 + + Args: + actions: L3 Actions 实例 + db_reader: 保留参数兼容性(不再用于 verify,仅供 base 类) + session_cache: 会话缓存(SessionCache 实例) + verify_bus: SSE 发送结果校验总线(SSEVerifyBus 实例)。 + None 时 verified 永远为 False(不阻塞发送)。 + """ super().__init__(actions, db_reader) self.session_cache = session_cache - # 基线消息的 local_id,与 before_msg_id(create_time)配合用于 DB 游标查询 - self._before_msg_local_id: int = 0 + self.verify_bus = verify_bus async def run(self, ctx: FlowContext) -> FlowResult: """执行发送文本流程。""" started_at = time.monotonic() - # 后台 task:获取 before_msg_id(与 _activate 并行) - before_msg_task: Optional[asyncio.Task] = None logger.info( "[flow:send_text] START to_wxid=%s display_name=%s content_len=%d client_request_id=%s", @@ -70,9 +76,6 @@ class SendTextFlow(Flow): # 跳过 click_search_box ~ open_session,但仍需激活窗口 # (30s 内用户可能切到其他应用,微信失去焦点) t0 = time.perf_counter() - before_msg_task = asyncio.create_task( - self._get_latest_create_time(ctx.to_wxid) - ) # 仅激活窗口 + 获取几何,跳过 Esc 清场(会话已打开) await self.actions.backend.activate_window(_WECHAT_WINDOW_TITLE) ctx.win_geom = self._ensure_valid_geometry( @@ -90,33 +93,17 @@ class SendTextFlow(Flow): else: # 完整流程 await self._activate(ctx) - # 并行启动 before_msg_id 获取 - before_msg_task = asyncio.create_task( - self._get_latest_create_time(ctx.to_wxid) - ) await self._click_search_box(ctx) await self._type_query(ctx) await self._open_session(ctx) await self._focus_input(ctx) await self._type_content(ctx) - # 等待 before_msg_task 完成(如果已启动) - if before_msg_task is not None: - ct, lid = await before_msg_task - ctx.before_msg_id = ct - self._before_msg_local_id = lid - logger.info( - "[flow:send_text] before_msg baseline ct=%d local_id=%d", - ct, lid, - ) - else: - logger.warning( - "[flow:send_text] before_msg_task is None, baseline unavailable" - ) await self._send(ctx) await self._cleanup(ctx) # 成功:更新会话缓存(用 to_wxid 做 key) + # SSE verify 通过才缓存,避免发错会话被缓存 if self.session_cache is not None and ctx.verified: self.session_cache.set(ctx.to_wxid) @@ -182,52 +169,28 @@ class SendTextFlow(Flow): state=FlowState.FAILED, duration_ms=(time.monotonic() - started_at) * 1000, ) - finally: - # 兜底:确保 before_msg_task 被取消(CancelledError 路径也走这里) - # Python 3.8+ 中 CancelledError 继承自 BaseException, - # except Exception 无法捕获,finally 块保证清理执行 - if before_msg_task is not None and not before_msg_task.done(): - before_msg_task.cancel() - try: - await before_msg_task - except (asyncio.CancelledError, Exception): - pass # ------------------------------------------------------------------ # Transitions # ------------------------------------------------------------------ - def _ensure_valid_geometry(self, geom: Optional[WindowGeometry]) -> WindowGeometry: - """校验窗口几何有效,防止拿到 1×1 隐藏窗后继续误点 (0,0)。 - - 返回校验后的 WindowGeometry,方便调用方获得窄化类型。 - """ - if geom is None: - raise PermanentError( - "WINDOW_GEOMETRY_INVALID", - "无法获取微信窗口几何,无法执行 UI 操作", - ) - if geom.width < 200 or geom.height < 200: - raise PermanentError( - "WINDOW_GEOMETRY_INVALID", - f"微信窗口几何异常 {geom.width}x{geom.height},无法执行 UI 操作", - ) - return geom - async def _activate(self, ctx: FlowContext) -> None: - """激活窗口 + 获取几何 + 重置状态(回到会话列表首页)。""" + """激活窗口 + Esc 清场(关闭残留搜索框/弹窗)。""" + t0 = time.perf_counter() + logger.info("[flow:send_text] step=activate start") await self.actions.backend.activate_window(_WECHAT_WINDOW_TITLE) ctx.win_geom = self._ensure_valid_geometry( await self.actions.backend.get_window_geometry(_WECHAT_WINDOW_TITLE) ) - # 重置状态:点击左侧"会话管理"图标,强制回到会话列表首页, - # 清除可能存在的搜索覆盖层、弹窗或非会话页面。 + # 点击 session_manager_icon 强制回到会话列表首页,清除搜索覆盖层 await self.actions.click_element("session_manager_icon", ctx.win_geom) + # Esc 关闭可能的弹窗/搜索框 await self.actions.backend.key_press("Escape") await asyncio.sleep(0.3) ctx.current_state = FlowState.WINDOW_ACTIVATED logger.info( - "[flow:send_text] activated window geom=%dx%d", + "[flow:send_text] activated window geom=%dx%d (%.0fms)", ctx.win_geom.width, ctx.win_geom.height, + (time.perf_counter() - t0) * 1000, ) async def _click_search_box(self, ctx: FlowContext) -> None: @@ -236,8 +199,6 @@ class SendTextFlow(Flow): logger.info("[flow:send_text] step=search_box click start") await self.actions.click_element("search_box", ctx.win_geom) ctx.current_state = FlowState.SEARCH_BOX_CLICKED - # 点击后短暂等待 UI 响应,高并发场景下调短以提升吞吐 - await asyncio.sleep(0.2) logger.info( "[flow:send_text] step=search_box click done (%.0fms)", (time.perf_counter() - t0) * 1000, @@ -256,47 +217,31 @@ class SendTextFlow(Flow): await asyncio.sleep(0.2) await self.actions.backend.type_text(ctx.display_name) ctx.current_state = FlowState.QUERY_TYPED - # 自适应等待搜索结果渲染;P1 阶段无 OpenCV 时 find_element 的 geom 兜底 - # 不可信,因此 _check_search_result_appeared 只在 image 命中时返回 True。 - appeared = await self._wait_for_condition( - lambda: self._check_search_result_appeared(ctx), - timeout=1.5, - interval=0.3, + # 纯坐标模式下无法用 OpenCV 验证搜索结果是否渲染, + # 改为固定等待 0.5s 让微信渲染搜索结果列表。 + await asyncio.sleep(0.5) + logger.info( + "[flow:send_text] step=type_query done (fixed wait 0.5s, %.0fms)", + (time.perf_counter() - t0) * 1000, ) - if not appeared: - logger.warning( - "[flow:send_text] search result not appeared in 1.5s, continue (%.0fms)", - (time.perf_counter() - t0) * 1000, - ) - else: - logger.info( - "[flow:send_text] step=type_query done (search result appeared, %.0fms)", - (time.perf_counter() - t0) * 1000, - ) async def _open_session(self, ctx: FlowContext) -> None: - """点击搜索结果第一项打开会话。""" + """按 Enter 打开搜索结果第一项(比点击坐标更可靠)。""" t0 = time.perf_counter() - logger.info("[flow:send_text] step=open_session click search_result_first start") - await self.actions.click_element("search_result_first", ctx.win_geom) - ctx.current_state = FlowState.SESSION_OPENED - # 自适应等待 input_box 出现;P1 阶段无 OpenCV 时 find_element 的 geom 兜底 - # 不可信,因此 _check_input_box_appeared 只在 image 命中时返回 True。 - appeared = await self._wait_for_condition( - lambda: self._check_input_box_appeared(ctx), - timeout=1.5, - interval=0.3, + logger.info( + "[flow:send_text] step=open_session press Enter to open first search result" + ) + # 微信 4.x 搜索框输入 display_name 后,直接按 Enter 即可打开第一项搜索结果。 + # 这比 click search_result_first 的固定坐标更稳定,不受"搜索网络结果" / + # "最近在搜" / 联系人结果排序变化的影响。 + await self.actions.backend.key_press("Return") + ctx.current_state = FlowState.SESSION_OPENED + # 等待会话切换完成、输入框自动聚焦。 + await asyncio.sleep(0.5) + logger.info( + "[flow:send_text] step=open_session done (fixed wait 0.5s, %.0fms)", + (time.perf_counter() - t0) * 1000, ) - if not appeared: - logger.warning( - "[flow:send_text] input_box not appeared in 1.5s, continue (%.0fms)", - (time.perf_counter() - t0) * 1000, - ) - else: - logger.info( - "[flow:send_text] step=open_session done (input_box appeared, %.0fms)", - (time.perf_counter() - t0) * 1000, - ) async def _focus_input(self, ctx: FlowContext) -> None: """聚焦输入框。""" @@ -321,7 +266,7 @@ class SendTextFlow(Flow): "original_len=%d cleaned_len=%d", len(ctx.content), len(cleaned_content), ) - ctx.content = cleaned_content + ctx.content = cleaned_content logger.info( "[flow:send_text] step=type_content start content_len=%d", len(ctx.content), @@ -334,13 +279,46 @@ class SendTextFlow(Flow): ) async def _send(self, ctx: FlowContext) -> None: - """点击发送按钮 + 校验。""" + """触发发送 + SSE 校验。 + + 纯坐标模式(WOC_DISABLE_OPENCV=1)下用 Enter 键发送, + 避免发送按钮坐标偏移导致点空。微信 4.x 默认 Enter 即发送。 + OpenCV 模式保留点击 send_button(坐标+模板双重保障)。 + + 关键:register 必须在 send 之前,否则存在竞态条件: + - T0: 按 Enter 发送 + - T0+0.05: 微信写 DB + - T0+0.05~T0+1.0: MessageStreamer 轮询 + 广播 + notify(无 waiter → 丢失) + - T0+0.1: register(已晚于 notify) + 先注册 future,发送后等待 notify 匹配,超时(2.5s)未匹配则 + verified=False,不阻塞发送成功语义(success=True),调用方按需重试。 + """ t0 = time.perf_counter() - logger.info("[flow:send_text] step=send click send_button start") - await self.actions.click_element("send_button", ctx.win_geom) + from woc_bridge.ui.actions import _DISABLE_OPENCV + + # 1. 先注册 SSE 校验等待,避免 send 后 broadcast 已发生的竞态 + verify_future = None + if self.verify_bus is not None: + verify_future = self.verify_bus.register( + to_wxid=ctx.to_wxid, + content=ctx.content, + ) + + # 2. 触发发送 + if _DISABLE_OPENCV: + logger.info("[flow:send_text] step=send press Enter start") + # 短暂等待输入框聚焦稳定,避免 type_text 后焦点未完全落位 + await asyncio.sleep(0.1) + await self.actions.backend.key_press("Return") + logger.info("[flow:send_text] step=send press Enter done") + else: + logger.info("[flow:send_text] step=send click send_button start") + await self.actions.click_element("send_button", ctx.win_geom) ctx.current_state = FlowState.SENT ctx.local_send_id = f"local_{int(time.time())}_{random.randint(1000,9999)}" - ctx.verified = await self._verify_sent(ctx) + + # 3. 等待 SSE 校验结果 + ctx.verified = await self._wait_verify(verify_future, ctx) logger.info( "[flow:send_text] step=send done local_id=%s verified=%s (%.0fms)", ctx.local_send_id, ctx.verified, @@ -360,144 +338,43 @@ class SendTextFlow(Flow): ) # ------------------------------------------------------------------ - # 校验 + # 校验:SSE 推送匹配 # ------------------------------------------------------------------ - async def _verify_sent(self, ctx: FlowContext) -> bool: - """发送结果校验:DB 校验优先,失败降级截图校验。""" - t0 = time.perf_counter() - if self.db_reader is None: - logger.warning( - "[flow:send_text] no db_reader, fall back to screenshot verify" - ) - result = await self._verify_by_screenshot(ctx) - logger.info( - "[flow:send_text] _verify_sent done (no db_reader) result=%s (%.0fms)", - result, (time.perf_counter() - t0) * 1000, - ) - return result - # before_msg_id=0 表示无法建立基线(DB 不可用或会话无历史消息), - # 直接降级截图校验,避免 cursor=0 查询返回所有历史消息导致假阳性 - if ctx.before_msg_id <= 0: - logger.warning( - "[flow:send_text] before_msg_id=%d, skip DB verify, fall back to screenshot", - ctx.before_msg_id, - ) - result = await self._verify_by_screenshot(ctx) - logger.info( - "[flow:send_text] _verify_sent done (no baseline) result=%s (%.0fms)", - result, (time.perf_counter() - t0) * 1000, - ) - return result + async def _wait_verify(self, future: Optional[asyncio.Future], ctx: FlowContext) -> bool: + """等待 SSEVerifyBus 的 future 完成。 - deadline = time.monotonic() + _DB_VERIFY_TIMEOUT_SEC - attempts = 0 - while time.monotonic() < deadline: - attempts += 1 - try: - result = await asyncio.to_thread( - self.db_reader.get_messages_by_session, - ctx.to_wxid, - cursor=ctx.before_msg_id, - cursor_local_id=self._before_msg_local_id, - limit=3, - direction="after", - is_sender=None, - ) - messages = result.get("messages", []) - if messages: - # 防串号:校验是否有消息内容匹配发送的 content - # 注意:get_messages_by_session 按 talker 分表查询,talker 字段恒等于 to_wxid, - # 无法通过 talker 字段检测串号。改为内容匹配校验。 - for msg in messages: - msg_content = msg.get("content", "") - # 微信消息内容可能包含发送者前缀(群消息),用 in 匹配 - if ctx.content and ctx.content in msg_content: - logger.info( - "[flow:send_text] DB verify OK (attempt=%d, content matched, is_sender=%s, %d new msgs)", - attempts, msg.get("is_sender"), len(messages), - ) - return True - # 有新消息但内容不匹配 → 可能串号或消息未到达 - logger.warning( - "[flow:send_text] DB verify: %d new msgs but content not matched (attempt=%d, contents=%s)", - len(messages), attempts, - [m.get("content", "")[:50] for m in messages], - ) - # 继续轮询,可能是 DB 写入延迟 - except Exception as exc: - logger.debug( - "[flow:send_text] DB verify attempt=%d exception: %s", - attempts, exc, - ) - await asyncio.sleep(_DB_VERIFY_INTERVAL_SEC) + future 由 _send 在触发发送前 register,确保 MessageStreamer + 广播消息时 notify 能匹配到 waiter。匹配到返回 True,超时返回 False。 - logger.warning( - "[flow:send_text] DB verify timeout after %ds/%d attempts, fall back to screenshot", - _DB_VERIFY_TIMEOUT_SEC, attempts, - ) - result = await self._verify_by_screenshot(ctx) - logger.info( - "[flow:send_text] _verify_sent done (DB timeout) result=%s (%.0fms)", - result, (time.perf_counter() - t0) * 1000, - ) - return result - - async def _verify_by_screenshot(self, ctx: FlowContext) -> bool: - """截图校验:轮询等待 input_box_empty 模板出现。 - - 策略说明: - - OpenCV image 命中 → 可靠校验通过,返回 True - - geom 兜底命中 → 不可靠,继续轮询等待 image 命中;超时后返回 False - - 超时/异常 → 返回 False - - geom 兜底永远返回坐标,无法证明输入框已清空。以往乐观返回 True 会掩盖 - 真实发送失败,现改为只有 image 命中才认为校验通过。 + 无 DB 轮询、无截图校验、无熔断器,避免雪崩。 + future 为 None 时(verify_bus 未注入)直接返回 False,不阻塞发送。 """ - if ctx.win_geom is None: - logger.warning("[flow:send_text] screenshot verify skipped: win_geom is None") - return False - t0 = time.perf_counter() - deadline = time.monotonic() + 3.0 - attempts = 0 - last_strategy: Optional[str] = None - last_conf: float = 0.0 - while time.monotonic() < deadline: - attempts += 1 - try: - elem = await self.actions.find_element( - "input_box_empty", ctx.win_geom - ) - if elem is None: - continue - if elem.strategy == "image": - logger.info( - "[flow:send_text] screenshot verify OK (image matched, conf=%.3f, attempts=%d, %.0fms)", - elem.confidence, attempts, (time.perf_counter() - t0) * 1000, - ) - return True - # geom 兜底:记录策略,继续轮询看 image 是否能匹配 - last_strategy = elem.strategy - last_conf = elem.confidence - except Exception as exc: - logger.debug( - "[flow:send_text] screenshot verify attempt=%d exception: %s", - attempts, exc, - ) - await asyncio.sleep(0.3) - # 超时:只有 image 命中才算成功,geom 兜底不再乐观返回 - if last_strategy == "geom": + if future is None: logger.warning( - "[flow:send_text] screenshot verify FAIL: only geom fallback matched, " - "image not matched in 3s/%d attempts, %.0fms — cannot prove send success", - attempts, (time.perf_counter() - t0) * 1000, + "[flow:send_text] no verify_bus, skip SSE verify (verified=False)" + ) + return False + t0 = time.perf_counter() + try: + result = await self.verify_bus.wait(future) + elapsed_ms = (time.perf_counter() - t0) * 1000 + if result: + logger.info( + "[flow:send_text] SSE verify OK (talker=%s content matched, %.0fms)", + ctx.to_wxid, elapsed_ms, + ) + else: + logger.warning( + "[flow:send_text] SSE verify timeout (talker=%s no matching broadcast, %.0fms)", + ctx.to_wxid, elapsed_ms, + ) + return result + except Exception as exc: + logger.warning( + "[flow:send_text] SSE verify exception: %s (%.0fms)", + exc, (time.perf_counter() - t0) * 1000, ) return False - logger.warning( - "[flow:send_text] screenshot verify FAIL: input_box_empty not found in 3s " - "(attempts=%d, %.0fms)", - attempts, (time.perf_counter() - t0) * 1000, - ) - return False async def _verify_session_still_open(self, ctx: FlowContext) -> None: """会话缓存命中时校验会话仍打开(input_box 存在)。""" @@ -517,14 +394,12 @@ class SendTextFlow(Flow): ) raise PermanentError( "ELEMENT_NOT_FOUND", - "session cache hit but input_box not found", + "session cache verify failed: input_box not found", ) logger.info( - "[flow:send_text] session_still_open OK (input_box found, strategy=%s, %.0fms)", - elem.strategy, (time.perf_counter() - t0) * 1000, + "[flow:send_text] session_still_open OK (%.0fms)", + (time.perf_counter() - t0) * 1000, ) - except PermanentError: - raise except Exception as exc: logger.warning( "[flow:send_text] session_still_open exception: %s (%.0fms)", @@ -534,88 +409,3 @@ class SendTextFlow(Flow): "ELEMENT_NOT_FOUND", f"session cache verify failed: {exc}", ) - - # ------------------------------------------------------------------ - # 自适应轮询 - # ------------------------------------------------------------------ - async def _wait_for_condition( - self, - condition_fn: Callable[[], Awaitable[bool]], - timeout: float, - interval: float, - ) -> bool: - """自适应轮询:condition_fn 返回 True 时立即返回,超时返回 False。""" - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - try: - if await condition_fn(): - return True - except Exception: - pass - await asyncio.sleep(interval) - return False - - async def _check_search_result_appeared(self, ctx: FlowContext) -> bool: - """检查搜索结果是否真正出现。 - - P1 阶段:find_element 在 image 匹配失败时会回退到 geom 兜底, - 而 geom 兜底只是返回 YAML profile 中写死的比例坐标,不能证明 - 搜索结果已渲染。因此只有 image 命中(strategy == "image") - 才视为可靠出现。 - """ - try: - elem = await self.actions.find_element( - "search_result_first", ctx.win_geom - ) - return elem is not None and elem.strategy == "image" - except Exception: - return False - - async def _check_input_box_appeared(self, ctx: FlowContext) -> bool: - """检查输入框是否真正出现。 - - 同 _check_search_result_appeared,只有 image 命中才视为可靠。 - """ - try: - elem = await self.actions.find_element("input_box", ctx.win_geom) - return elem is not None and elem.strategy == "image" - except Exception: - return False - - # ------------------------------------------------------------------ - # DB 辅助 - # ------------------------------------------------------------------ - async def _get_latest_create_time(self, to_wxid: str) -> tuple[int, int]: - """获取目标会话当前最新消息的 (create_time, local_id)。""" - if self.db_reader is None: - logger.debug("[flow:send_text] get_latest_create_time: db_reader is None") - return (0, 0) - t0 = time.perf_counter() - try: - result = await asyncio.to_thread( - self.db_reader.get_messages_by_session, - to_wxid, - cursor=0, - limit=1, - direction="before", - is_sender=None, - ) - messages = result.get("messages", []) - if messages: - ct = int(messages[0].get("create_time", 0)) - lid = int(messages[0].get("local_id", 0)) - logger.info( - "[flow:send_text] get_latest_create_time OK to_wxid=%s ct=%d local_id=%d (%.0fms)", - to_wxid, ct, lid, (time.perf_counter() - t0) * 1000, - ) - return (ct, lid) - logger.info( - "[flow:send_text] get_latest_create_time: no history msgs for to_wxid=%s (%.0fms)", - to_wxid, (time.perf_counter() - t0) * 1000, - ) - except Exception as exc: - logger.warning( - "[flow:send_text] get_latest_create_time exception: %s (%.0fms)", - exc, (time.perf_counter() - t0) * 1000, - ) - return (0, 0) diff --git a/bridge/woc_bridge/ui/locators/__init__.py b/bridge/woc_bridge/ui/locators/__init__.py index 08d16e5..05d96a0 100644 --- a/bridge/woc_bridge/ui/locators/__init__.py +++ b/bridge/woc_bridge/ui/locators/__init__.py @@ -1,10 +1,10 @@ -"""L2 Locator 抽象层:GeomSpec + Selector + ElementHandle + LocatorRegistry。 +"""L2 Locator 抽象层:GeomSpec + RoiSpec + Selector + ElementHandle + LocatorRegistry。 LocatorRegistry 按 (app_version, resolution) 加载 YAML profile, 返回 Selector 给 L3 Actions,Actions 据此调 L1 Backend 执行点击。 """ -from woc_bridge.ui.locators.base import ElementHandle, GeomSpec, Selector +from woc_bridge.ui.locators.base import ElementHandle, GeomSpec, RoiSpec, Selector from woc_bridge.ui.locators.registry import LocatorRegistry -__all__ = ["GeomSpec", "Selector", "ElementHandle", "LocatorRegistry"] +__all__ = ["GeomSpec", "RoiSpec", "Selector", "ElementHandle", "LocatorRegistry"] diff --git a/bridge/woc_bridge/ui/locators/base.py b/bridge/woc_bridge/ui/locators/base.py index 989ed63..39e0f3f 100644 --- a/bridge/woc_bridge/ui/locators/base.py +++ b/bridge/woc_bridge/ui/locators/base.py @@ -1,7 +1,8 @@ -"""L2 Locator 抽象层基类:GeomSpec + Selector + ElementHandle。 +"""L2 Locator 抽象层基类:GeomSpec + RoiSpec + Selector + ElementHandle。 GeomSpec:几何定位规格(relative_to / ratio / offset) -Selector:UI 元素选择器(kind / by_image / by_geom / threshold / require_image) +RoiSpec:图像匹配感兴趣区域(相对窗口的比例矩形,缩小 OpenCV 搜索范围) +Selector:UI 元素选择器(kind / by_image / by_geom / roi / threshold / require_image) ElementHandle:定位结果句柄(绝对坐标 + 置信度 + 策略) L3 Actions 通过 Selector 描述要找的元素,LocatorRegistry 解析为 @@ -31,6 +32,32 @@ class GeomSpec: y_offset: int = 0 +@dataclass +class RoiSpec: + """图像匹配感兴趣区域(ROI),相对窗口的比例矩形。 + + 用于将 OpenCV 模板匹配限制在窗口的某个子区域,避免误匹配到 + 窗口内其他视觉相似的 UI 元素。例如 input_box 只在聊天区右下角查找, + 即使模板图像有缺陷也不会匹配到窗口中部。 + + 所有字段为相对窗口宽/高的比例(0.0~1.0),由 Actions 按当前窗口几何 + 转换为绝对像素 ROI 传给 OpenCVBackend.find_template。 + + 字段: + x_ratio: ROI 左上角相对窗口左上角的 X 比例 + y_ratio: ROI 左上角相对窗口左上角的 Y 比例 + w_ratio: ROI 宽度相对窗口宽度的比例 + h_ratio: ROI 高度相对窗口高度的比例 + + 边界由 Actions/opencv.py 做裁剪保护,YAML 中可略微宽松。 + """ + + x_ratio: float = 0.0 + y_ratio: float = 0.0 + w_ratio: float = 1.0 + h_ratio: float = 1.0 + + @dataclass class Selector: """UI 元素选择器。 @@ -38,6 +65,8 @@ class Selector: kind:元素类别键(如 search_box / send_button / input_box) by_image:图像模板名(P2 启用),非空时优先图像匹配 by_geom:几何规格,非空时图像失败或 P1 阶段走几何兜底 + roi:图像匹配感兴趣区域(可选)。非空时限制 OpenCV 搜索范围; + 为空时使用全窗口。用于防止模板误匹配到窗口其他相似元素。 threshold:图像匹配置信度阈值(默认 0.80) require_image:高风险操作为 True 时,图像失败即拒绝执行 description:人类可读描述 @@ -48,6 +77,7 @@ class Selector: kind: str = "" by_image: Optional[str] = None by_geom: Optional[GeomSpec] = None + roi: Optional[RoiSpec] = None threshold: float = 0.80 require_image: bool = False description: str = "" diff --git a/bridge/woc_bridge/ui/locators/registry.py b/bridge/woc_bridge/ui/locators/registry.py index 25062aa..c45fef7 100644 --- a/bridge/woc_bridge/ui/locators/registry.py +++ b/bridge/woc_bridge/ui/locators/registry.py @@ -15,7 +15,7 @@ from typing import Optional import yaml -from woc_bridge.ui.locators.base import GeomSpec, Selector +from woc_bridge.ui.locators.base import GeomSpec, RoiSpec, Selector logger = logging.getLogger("woc-bridge") @@ -198,6 +198,7 @@ class LocatorRegistry: 若提供 target_resolution 与 profile_resolution,则对 by_geom 中的 绝对偏移 x_offset / y_offset 按分辨率比例缩放(宽高比不变时坐标更接近目标)。 + roi 为相对窗口的比例矩形,与分辨率无关,不做缩放。 YAML 结构(每个 key 对应一个 Selector): search_box: @@ -208,6 +209,11 @@ class LocatorRegistry: y_ratio: 0.05 x_offset: 0 y_offset: 0 + roi: # dict,可省略(缺省=全窗口) + x_ratio: 0.0 + y_ratio: 0.0 + w_ratio: 0.30 + h_ratio: 0.20 threshold: 0.80 require_image: false description: 搜索框 @@ -240,10 +246,20 @@ class LocatorRegistry: x_offset=int(int(by_geom_raw.get("x_offset", 0)) * scale_x), y_offset=int(int(by_geom_raw.get("y_offset", 0)) * scale_y), ) + roi_raw = spec.get("roi") + roi: Optional[RoiSpec] = None + if isinstance(roi_raw, dict): + roi = RoiSpec( + x_ratio=float(roi_raw.get("x_ratio", 0.0)), + y_ratio=float(roi_raw.get("y_ratio", 0.0)), + w_ratio=float(roi_raw.get("w_ratio", 1.0)), + h_ratio=float(roi_raw.get("h_ratio", 1.0)), + ) self.selectors[kind] = Selector( kind=kind, by_image=by_image, by_geom=by_geom, + roi=roi, threshold=float(spec.get("threshold", 0.80)), require_image=bool(spec.get("require_image", False)), description=str(spec.get("description", "")), @@ -266,6 +282,9 @@ class LocatorRegistry: relative_to 字段仅作语义占位,实际坐标计算统一用 ratio + offset (offset 已含正负号区分方向,详见 Actions._geom_find)。 + + roi 字段同步与 YAML profile 保持一致,防止无 YAML 时退化为全窗口 + 匹配(全窗口易误匹配到窗口其他相似 UI 元素,参见 input_box 误匹配事件)。 """ defaults = { "session_manager_icon": Selector( @@ -277,6 +296,9 @@ class LocatorRegistry: x_offset=0, y_offset=0, ), + roi=RoiSpec( + x_ratio=0.0, y_ratio=0.0, w_ratio=0.10, h_ratio=0.25, + ), description="左侧导航栏顶部微信消息/会话管理图标", ), "search_box": Selector( @@ -288,6 +310,9 @@ class LocatorRegistry: x_offset=0, y_offset=0, ), + roi=RoiSpec( + x_ratio=0.0, y_ratio=0.0, w_ratio=0.30, h_ratio=0.20, + ), description="左侧顶部搜索框", ), "send_button": Selector( @@ -299,6 +324,9 @@ class LocatorRegistry: x_offset=0, y_offset=0, ), + roi=RoiSpec( + x_ratio=0.90, y_ratio=0.85, w_ratio=0.10, h_ratio=0.15, + ), description="右下角发送按钮", ), "input_box": Selector( @@ -310,6 +338,9 @@ class LocatorRegistry: x_offset=0, y_offset=0, ), + roi=RoiSpec( + x_ratio=0.55, y_ratio=0.78, w_ratio=0.45, h_ratio=0.22, + ), description="聊天输入框", ), "input_box_empty": Selector( @@ -321,6 +352,9 @@ class LocatorRegistry: x_offset=0, y_offset=0, ), + roi=RoiSpec( + x_ratio=0.55, y_ratio=0.78, w_ratio=0.45, h_ratio=0.22, + ), description="空输入框灰色提示态(发送成功后校验)", ), "search_result_first": Selector( @@ -332,6 +366,9 @@ class LocatorRegistry: x_offset=0, y_offset=0, ), + roi=RoiSpec( + x_ratio=0.0, y_ratio=0.10, w_ratio=0.30, h_ratio=0.40, + ), description="搜索结果第一项", ), "main_view": Selector( @@ -343,6 +380,7 @@ class LocatorRegistry: x_offset=0, y_offset=0, ), + # main_view 为大区域校验,无需 ROI 限制 description="主聊天视图(post_verify 校验用)", ), } diff --git a/bridge/woc_bridge/ui/orchestrator.py b/bridge/woc_bridge/ui/orchestrator.py index fe93053..2094032 100644 --- a/bridge/woc_bridge/ui/orchestrator.py +++ b/bridge/woc_bridge/ui/orchestrator.py @@ -1,12 +1,14 @@ -"""L5 FlowOrchestrator:编排幂等/熔断/会话缓存/队列/Flow。 +"""L5 FlowOrchestrator:编排幂等/会话缓存/队列/Flow。 send_text 流程: 1. 幂等检查(命中返回 skipped=true) -2. DB 校验熔断检查(OPEN 时返回 BRIDGE_CIRCUITED) -3. 入队(自适应 delay_ms:同联系人 1000 / 不同 config.send_delay_ms) +2. 入队(自适应 delay_ms:同联系人 1000 / 不同 config.send_delay_ms) +3. 执行 SendTextFlow(内部用 SSEVerifyBus 校验) 4. 成功且 verified 才写幂等缓存 -5. DB 校验失败计数 + 熔断 +5. verified=False 不阻塞发送、不熔断(仅记 warning,调用方按需重试) 6. 失败时 session_cache.invalidate(to_wxid)(仅失效当前联系人) + +熔断器已移除:SSE 校验超时不会导致雪崩,单次 verify 失败不应阻塞全局发送。 """ from __future__ import annotations @@ -22,8 +24,7 @@ from woc_bridge.models import BridgeError if TYPE_CHECKING: from woc_bridge.ui.flows.send_file import SendFileFlow from woc_bridge.ui.flows.send_text import SendTextFlow -from woc_bridge.ui.circuit_breaker import CircuitBreaker, CircuitState -from woc_bridge.ui.errors import FlowError, is_retryable +from woc_bridge.ui.errors import FlowError from woc_bridge.ui.flow import FlowContext, FlowResult, FlowState from woc_bridge.ui.idem_cache import IdemCache @@ -82,7 +83,7 @@ class SessionCache: class FlowOrchestrator: """L5 Flow 编排器。 - 整合幂等缓存、熔断器、会话缓存、发送队列、Flow 执行。 + 整合幂等缓存、会话缓存、发送队列、Flow 执行、SSE 发送结果校验。 """ def __init__( @@ -94,6 +95,7 @@ class FlowOrchestrator: config, send_text_flow: Optional[SendTextFlow] = None, send_file_flow: Optional[SendFileFlow] = None, + verify_bus=None, ) -> None: """初始化编排器。 @@ -106,6 +108,8 @@ class FlowOrchestrator: send_text_flow: 可选的 SendTextFlow 实例(用于测试注入) send_file_flow: 可选的 SendFileFlow 实例(必须由 app._init_state 注入, 因 SendFileFlow 依赖 XdotoolDriver,orchestrator 无法惰性创建) + verify_bus: SSE 发送结果校验总线(SSEVerifyBus 实例)。 + 惰性创建 SendTextFlow 时注入,用于 SSE 推送校验。 """ self.send_queue = send_queue self.idem_cache = idem_cache @@ -114,12 +118,9 @@ class FlowOrchestrator: self.config = config # LRU 多会话缓存:最多 16 个会话,30s TTL self.session_cache = SessionCache(ttl=30.0, max_size=16) - # DB 校验熔断器:10 次失败 / 30s 恢复(高并发下更宽容) - self.db_verify_breaker = CircuitBreaker( - "db_verify", failure_threshold=10, recovery_timeout=30.0 - ) self._send_text_flow = send_text_flow self._send_file_flow = send_file_flow + self._verify_bus = verify_bus def _get_send_text_flow(self): """获取 SendTextFlow 实例(惰性创建或使用注入的)。""" @@ -131,6 +132,7 @@ class FlowOrchestrator: actions=self.actions, db_reader=self.db_reader, session_cache=self.session_cache, + verify_bus=self._verify_bus, ) def _get_send_file_flow(self): @@ -158,10 +160,10 @@ class FlowOrchestrator: 流程: 1. 幂等检查 - 2. DB 校验熔断检查 - 3. 自适应延时入队 - 4. 执行 SendTextFlow - 5. 成功且 verified 写幂等缓存 + 2. 自适应延时入队 + 3. 执行 SendTextFlow(内部用 SSEVerifyBus 校验) + 4. 成功且 verified 写幂等缓存 + 5. verified=False 不阻塞、不熔断(调用方按需重试) 6. 失败时失效会话缓存 Args: @@ -206,21 +208,7 @@ class FlowOrchestrator: duration_ms=(time.monotonic() - started_at) * 1000, ) - # 2. DB 校验熔断检查 - if not self.db_verify_breaker.allow(): - logger.warning( - "[orchestrator] db_verify breaker OPEN, reject send (to_wxid=%s)", - to_wxid, - ) - return FlowResult( - success=False, - error="db_verify circuit breaker open", - error_code="BRIDGE_CIRCUITED", - state=FlowState.FAILED, - duration_ms=(time.monotonic() - started_at) * 1000, - ) - - # 3. 自适应延时:同联系人用短延时 + # 2. 自适应延时:同联系人用短延时 is_same_contact = self.session_cache.get(to_wxid) delay_ms = _SAME_CONTACT_DELAY_MS if is_same_contact else None # None 表示用 send_queue 的默认 send_delay_ms @@ -229,7 +217,7 @@ class FlowOrchestrator: is_same_contact, delay_ms, to_wxid, ) - # 4. 构造 FlowContext + # 3. 构造 FlowContext ctx = FlowContext( to_wxid=to_wxid, content=content, @@ -237,7 +225,7 @@ class FlowOrchestrator: client_request_id=client_request_id, ) - # 5. 入队执行(带队列等待超时,避免无界排队) + # 4. 入队执行(带队列等待超时,避免无界排队) flow = self._get_send_text_flow() try: result: FlowResult = await self.send_queue.enqueue( @@ -270,24 +258,23 @@ class FlowOrchestrator: details=exc_details, ) - # 6. 结果处理 + # 5. 结果处理 if result.success and result.verified: # 成功且 verified:写幂等缓存 self.idem_cache.set( "send_text", to_wxid, content, result, client_request_id ) - self.db_verify_breaker.record_success() logger.info( "[orchestrator] send_text DONE ✓ to_wxid=%s local_id=%s verified=%s (%.0fms)", to_wxid, result.local_id, result.verified, (time.monotonic() - started_at) * 1000, ) elif result.success and not result.verified: - # 发送成功但校验失败:DB 校验熔断计数 - self.db_verify_breaker.record_failure() + # 发送成功但 SSE 校验未匹配(超时/消息未到/发错会话) + # 不熔断、不阻塞:调用方按需重试或通过 SSE 自行确认 logger.warning( - "[orchestrator] send success but verify failed, breaker fail_count+1 (to_wxid=%s)", - to_wxid, + "[orchestrator] send success but verify unmatched (to_wxid=%s, local_id=%s)", + to_wxid, result.local_id, ) else: # 发送失败:仅失效当前联系人会话缓存,避免误伤其它缓存命中 @@ -307,15 +294,14 @@ class FlowOrchestrator: client_request_id: Optional[str] = None, display_name: Optional[str] = None, ) -> dict: - """文件发送 Flow 编排:幂等检查 + 熔断 + 入队 + DB 校验。 + """文件发送 Flow 编排:幂等检查 + 入队 + 校验。 流程: 1. 幂等检查(命中返回 skipped=True) - 2. DB 校验熔断检查(OPEN 时返回 BRIDGE_CIRCUITED) - 3. 入队执行 SendFileFlow.execute(路径白名单 + 文件选择器输入 + post_verify) - 4. 成功且 verified 写幂等缓存 - 5. DB 校验失败计数 + 熔断 - 6. 失败时失效会话缓存 + 2. 入队执行 SendFileFlow.execute(路径白名单 + 文件选择器输入 + post_verify) + 3. 成功且 verified 写幂等缓存 + 4. verified=False 不熔断、不阻塞(仅记 warning) + 5. 失败时失效会话缓存 幂等缓存键:(send_file, to_wxid, file_path, client_request_id) (IdemCache API 仅支持 4 段 key,file_path 作为 content 槽位传入) @@ -366,23 +352,10 @@ class FlowOrchestrator: "skipped": True, "error": None, "error_code": None, } - # 2. DB 校验熔断检查 - if not self.db_verify_breaker.allow(): - logger.warning( - "[orchestrator] send_file: db_verify breaker OPEN, reject (to_wxid=%s)", - to_wxid, - ) - return { - "success": False, "verified": False, "placeholder": False, - "skipped": False, - "error": "db_verify circuit breaker open", - "error_code": "BRIDGE_CIRCUITED", - } - - # 3. 获取 SendFileFlow 实例(未注入时抛 BridgeError) + # 2. 获取 SendFileFlow 实例(未注入时抛 BridgeError) flow = self._get_send_file_flow() - # 4. 入队执行(SendFileFlow.execute 内部完成路径校验 + 文件选择器输入 + post_verify) + # 3. 入队执行(SendFileFlow.execute 内部完成路径校验 + 文件选择器输入 + post_verify) logger.info( "[orchestrator] send_file enqueue (pending=%d)", self.send_queue.pending_count(), @@ -420,18 +393,16 @@ class FlowOrchestrator: "error_code": "SEND_FAILED", } - # 5. 结果处理 + # 4. 结果处理 # SendFileFlow.execute 返回 {"success": bool, "verified": Optional[bool], "error": Optional[str]} - # verified=None 表示无法校验(DB 不可用或无基线),不计熔断失败 success = result.get("success", False) verified = result.get("verified", False) if success and verified: - # 成功且 verified:写幂等缓存 + 熔断成功 + # 成功且 verified:写幂等缓存 self.idem_cache.set( "send_file", to_wxid, file_path, result, idem_id ) - self.db_verify_breaker.record_success() # 更新会话缓存(send_file 打开了 to_wxid 会话) self.session_cache.set(to_wxid) logger.info( @@ -440,20 +411,17 @@ class FlowOrchestrator: (time.monotonic() - started_at) * 1000, ) elif success and verified is False: - # 发送成功但校验失败(真正的超时/未匹配):DB 校验熔断计数 - self.db_verify_breaker.record_failure() + # 发送成功但校验失败:不熔断、不阻塞 self.session_cache.set(to_wxid) logger.warning( - "[orchestrator] send_file success but verify failed, breaker fail_count+1 (to_wxid=%s)", + "[orchestrator] send_file success but verify unmatched (to_wxid=%s)", to_wxid, ) elif success and verified is None: - # P0 修复:发送成功但无法校验(DB 不可用或无基线): - # 不计熔断失败,避免 DB 不可用时熔断器雪崩阻塞全部发送。 - # 也不写幂等缓存(未校验的结果不应被复用)。 + # 发送成功但无法校验:不写幂等缓存,不熔断 self.session_cache.set(to_wxid) logger.info( - "[orchestrator] send_file success but verify skipped (DB unavailable), breaker untouched (to_wxid=%s)", + "[orchestrator] send_file success but verify skipped (to_wxid=%s)", to_wxid, ) else: diff --git a/bridge/woc_bridge/ui/profiles/default.yaml b/bridge/woc_bridge/ui/profiles/default.yaml index 9b45ce4..7a2ce61 100644 --- a/bridge/woc_bridge/ui/profiles/default.yaml +++ b/bridge/woc_bridge/ui/profiles/default.yaml @@ -10,8 +10,9 @@ search_box: by_geom: relative_to: window - x_ratio: 0.092 - y_ratio: 0.095 + # 与 1435x876 共用比例标定:搜索框中心位于左上角约 (0.080,0.052) + x_ratio: 0.080 + y_ratio: 0.052 x_offset: 0 y_offset: 0 threshold: 0.80 diff --git a/bridge/woc_bridge/ui/profiles/wechat_4.0_1280x720.yaml b/bridge/woc_bridge/ui/profiles/wechat_4.0_1280x720.yaml index 08f0778..d216106 100644 --- a/bridge/woc_bridge/ui/profiles/wechat_4.0_1280x720.yaml +++ b/bridge/woc_bridge/ui/profiles/wechat_4.0_1280x720.yaml @@ -4,17 +4,43 @@ # 几何兜底按 1280x720 比例计算,与 1920x1080 相同 ratio; # offset 按 2/3 缩放(1280/1920 = 720/1080 = 2/3)。 # 图像模板与 1920x1080 共用(多尺度匹配自适应)。 +# roi 为相对窗口的比例矩形,与分辨率无关,与 1435x876/1920x1080 共用相同比例值。 # -# 字段说明:参考 wechat_4.0_1920x1080.yaml 顶部注释。 +# 字段说明:参考 wechat_4.0_1435x876.yaml 顶部注释。 + +# 会话管理图标:左侧导航栏最上方(头像下方第一个)微信消息图标。 +# 每次发送前点击它,强制回到会话列表首页,清除搜索覆盖层/弹窗/非会话页面。 +session_manager_icon: + by_image: session_manager_icon.png + by_geom: + relative_to: window + x_ratio: 0.018 + y_ratio: 0.109 + x_offset: 0 + y_offset: 0 + roi: + x_ratio: 0.0 + y_ratio: 0.0 + w_ratio: 0.10 + h_ratio: 0.25 + threshold: 0.80 + require_image: false + description: 左侧导航栏顶部微信消息/会话管理图标 search_box: by_image: search_box.png by_geom: relative_to: window - x_ratio: 0.092 - y_ratio: 0.095 + # 与 1435x876 共用比例标定:搜索框中心位于左上角约 (0.080,0.052) + x_ratio: 0.080 + y_ratio: 0.052 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.0 + y_ratio: 0.0 + w_ratio: 0.30 + h_ratio: 0.12 threshold: 0.80 require_image: false description: 左侧顶部搜索框 @@ -27,6 +53,11 @@ send_button: y_ratio: 0.929 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.90 + y_ratio: 0.85 + w_ratio: 0.10 + h_ratio: 0.15 threshold: 0.80 require_image: false description: 右下角发送按钮 @@ -39,6 +70,11 @@ input_box: y_ratio: 0.914 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.55 + y_ratio: 0.78 + w_ratio: 0.45 + h_ratio: 0.22 threshold: 0.80 require_image: false description: 聊天输入框 @@ -51,6 +87,11 @@ input_box_empty: y_ratio: 0.914 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.55 + y_ratio: 0.78 + w_ratio: 0.45 + h_ratio: 0.22 threshold: 0.80 require_image: false description: 空输入框灰色提示态(发送成功后校验用) @@ -63,6 +104,11 @@ search_result_first: y_ratio: 0.174 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.0 + y_ratio: 0.10 + w_ratio: 0.30 + h_ratio: 0.40 threshold: 0.80 require_image: false description: 搜索结果第一项 @@ -76,7 +122,7 @@ main_view: y_offset: 0 threshold: 0.75 require_image: false - description: 主聊天视图(post_verify 校验用,模板匹配不稳定,使用几何兜底) + description: 主聊天视图(post_verify 校验用,模板匹配不稳定,使用几何兜底;大区域无需 ROI 限制) # 好友申请通过 UI 元素定位(experimental,坐标为估算值需实测校准) contact_icon: @@ -87,6 +133,11 @@ contact_icon: y_ratio: 0.171 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.0 + y_ratio: 0.05 + w_ratio: 0.10 + h_ratio: 0.30 threshold: 0.80 require_image: false description: 左侧导航栏通讯录图标 @@ -99,6 +150,11 @@ new_friend_entry: y_ratio: 0.138 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.05 + y_ratio: 0.10 + w_ratio: 0.25 + h_ratio: 0.20 threshold: 0.80 require_image: false description: 通讯录页"新的朋友"入口 @@ -110,6 +166,11 @@ friend_request_item: y_ratio: 0.178 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.05 + y_ratio: 0.15 + w_ratio: 0.30 + h_ratio: 0.30 threshold: 0.75 require_image: false description: '"新的朋友"列表第一条申请项(模板匹配不稳定,使用几何兜底)' @@ -122,6 +183,11 @@ verify_button: y_ratio: 0.394 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.40 + y_ratio: 0.20 + w_ratio: 0.40 + h_ratio: 0.40 threshold: 0.75 require_image: false description: 好友详情页"前往验证"按钮(OpenCV 实测标定) @@ -133,6 +199,11 @@ confirm_button: y_ratio: 0.830 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.30 + y_ratio: 0.65 + w_ratio: 0.40 + h_ratio: 0.30 threshold: 0.75 require_image: false description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)' diff --git a/bridge/woc_bridge/ui/profiles/wechat_4.0_1435x876.yaml b/bridge/woc_bridge/ui/profiles/wechat_4.0_1435x876.yaml index be9039e..1e792f9 100644 --- a/bridge/woc_bridge/ui/profiles/wechat_4.0_1435x876.yaml +++ b/bridge/woc_bridge/ui/profiles/wechat_4.0_1435x876.yaml @@ -10,6 +10,12 @@ # relative_to: window / window_bottom_right(当前统一按 window 处理) # x_ratio / y_ratio: 相对窗口宽高的比例位置 # x_offset / y_offset: 绝对像素偏移(含正负号) +# roi: 图像匹配感兴趣区域(可选,缺省=全窗口) +# 将 OpenCV 模板匹配限制在窗口的某个子区域,避免误匹配到窗口其他 +# 视觉相似的 UI 元素。所有字段为相对窗口宽/高的比例(0.0~1.0), +# 与分辨率无关,3 个 profile 共用相同比例值。 +# x_ratio / y_ratio: ROI 左上角相对窗口左上角的比例 +# w_ratio / h_ratio: ROI 宽/高相对窗口宽/高的比例 # threshold: 图像匹配置信度阈值 # require_image: true 时图像失败拒绝执行(高风险元素) # description: 元素描述 @@ -24,6 +30,11 @@ session_manager_icon: y_ratio: 0.109 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.0 + y_ratio: 0.0 + w_ratio: 0.10 + h_ratio: 0.25 threshold: 0.80 require_image: false description: 左侧导航栏顶部微信消息/会话管理图标 @@ -32,10 +43,16 @@ search_box: by_image: search_box.png by_geom: relative_to: window - x_ratio: 0.092 - y_ratio: 0.095 + # 基于 1435x876 实测截图标定:搜索框中心约 (115,45) + x_ratio: 0.080 + y_ratio: 0.052 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.0 + y_ratio: 0.0 + w_ratio: 0.30 + h_ratio: 0.12 threshold: 0.80 require_image: false description: 左侧顶部搜索框 @@ -48,6 +65,11 @@ send_button: y_ratio: 0.929 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.90 + y_ratio: 0.85 + w_ratio: 0.10 + h_ratio: 0.15 threshold: 0.80 require_image: false description: 右下角发送按钮 @@ -60,6 +82,11 @@ input_box: y_ratio: 0.914 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.55 + y_ratio: 0.78 + w_ratio: 0.45 + h_ratio: 0.22 threshold: 0.80 require_image: false description: 聊天输入框 @@ -72,6 +99,11 @@ input_box_empty: y_ratio: 0.914 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.55 + y_ratio: 0.78 + w_ratio: 0.45 + h_ratio: 0.22 threshold: 0.80 require_image: false description: 空输入框灰色提示态(发送成功后校验用) @@ -84,6 +116,11 @@ search_result_first: y_ratio: 0.174 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.0 + y_ratio: 0.10 + w_ratio: 0.30 + h_ratio: 0.40 threshold: 0.80 require_image: false description: 搜索结果第一项 @@ -98,7 +135,7 @@ main_view: y_offset: 0 threshold: 0.75 require_image: false - description: 主聊天视图(post_verify 校验用,阈值略低因区域大) + description: 主聊天视图(post_verify 校验用,阈值略低因区域大;大区域无需 ROI 限制) # 好友申请通过 UI 元素定位(基于 1435x876 实测标定) contact_icon: @@ -109,6 +146,11 @@ contact_icon: y_ratio: 0.171 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.0 + y_ratio: 0.05 + w_ratio: 0.10 + h_ratio: 0.30 threshold: 0.80 require_image: false description: 左侧导航栏通讯录图标 @@ -121,6 +163,11 @@ new_friend_entry: y_ratio: 0.138 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.05 + y_ratio: 0.10 + w_ratio: 0.25 + h_ratio: 0.20 threshold: 0.80 require_image: false description: 通讯录页"新的朋友"入口 @@ -133,6 +180,11 @@ friend_request_item: y_ratio: 0.178 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.05 + y_ratio: 0.15 + w_ratio: 0.30 + h_ratio: 0.30 threshold: 0.75 require_image: false description: '"新的朋友"列表第一条申请项' @@ -145,6 +197,11 @@ verify_button: y_ratio: 0.394 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.40 + y_ratio: 0.20 + w_ratio: 0.40 + h_ratio: 0.40 threshold: 0.75 require_image: false description: 好友详情页"前往验证"按钮(OpenCV 实测标定) @@ -156,6 +213,11 @@ confirm_button: y_ratio: 0.830 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.30 + y_ratio: 0.65 + w_ratio: 0.40 + h_ratio: 0.30 threshold: 0.75 require_image: false description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)' diff --git a/bridge/woc_bridge/ui/profiles/wechat_4.0_1920x1080.yaml b/bridge/woc_bridge/ui/profiles/wechat_4.0_1920x1080.yaml index ea3c24a..94b7c51 100644 --- a/bridge/woc_bridge/ui/profiles/wechat_4.0_1920x1080.yaml +++ b/bridge/woc_bridge/ui/profiles/wechat_4.0_1920x1080.yaml @@ -2,25 +2,43 @@ # # 适用场景:微信窗口在 1920x1080 桌面环境下运行。 # 图像模板位于 templates/wechat_4.0/light/,几何兜底基于 1920x1080 比例计算。 +# roi 为相对窗口的比例矩形,与分辨率无关,与 1435x876/1280x720 共用相同比例值。 # -# 字段说明: -# by_image: 模板文件名(相对 templates/wechat_4.0/light/) -# by_geom: 几何兜底规格(图像匹配失败或熔断时使用) -# relative_to: 语义占位(window / window_bottom_right) -# x_ratio / y_ratio: 相对窗口宽高的比例位置 -# x_offset / y_offset: 绝对像素偏移(含正负号) -# threshold: 图像匹配置信度阈值 -# require_image: true 时图像失败拒绝执行(高风险元素) -# description: 元素描述 +# 字段说明:参考 wechat_4.0_1435x876.yaml 顶部注释。 + +# 会话管理图标:左侧导航栏最上方(头像下方第一个)微信消息图标。 +# 每次发送前点击它,强制回到会话列表首页,清除搜索覆盖层/弹窗/非会话页面。 +session_manager_icon: + by_image: session_manager_icon.png + by_geom: + relative_to: window + x_ratio: 0.018 + y_ratio: 0.109 + x_offset: 0 + y_offset: 0 + roi: + x_ratio: 0.0 + y_ratio: 0.0 + w_ratio: 0.10 + h_ratio: 0.25 + threshold: 0.80 + require_image: false + description: 左侧导航栏顶部微信消息/会话管理图标 search_box: by_image: search_box.png by_geom: relative_to: window - x_ratio: 0.092 - y_ratio: 0.095 + # 与 1435x876 共用比例标定:搜索框中心位于左上角约 (0.080,0.052) + x_ratio: 0.080 + y_ratio: 0.052 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.0 + y_ratio: 0.0 + w_ratio: 0.30 + h_ratio: 0.12 threshold: 0.80 require_image: false description: 左侧顶部搜索框 @@ -33,6 +51,11 @@ send_button: y_ratio: 0.929 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.90 + y_ratio: 0.85 + w_ratio: 0.10 + h_ratio: 0.15 threshold: 0.80 require_image: false description: 右下角发送按钮 @@ -45,6 +68,11 @@ input_box: y_ratio: 0.914 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.55 + y_ratio: 0.78 + w_ratio: 0.45 + h_ratio: 0.22 threshold: 0.80 require_image: false description: 聊天输入框 @@ -57,6 +85,11 @@ input_box_empty: y_ratio: 0.914 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.55 + y_ratio: 0.78 + w_ratio: 0.45 + h_ratio: 0.22 threshold: 0.80 require_image: false description: 空输入框灰色提示态(发送成功后校验用) @@ -69,6 +102,11 @@ search_result_first: y_ratio: 0.174 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.0 + y_ratio: 0.10 + w_ratio: 0.30 + h_ratio: 0.40 threshold: 0.80 require_image: false description: 搜索结果第一项 @@ -82,7 +120,7 @@ main_view: y_offset: 0 threshold: 0.75 require_image: false - description: 主聊天视图(post_verify 校验用,模板匹配不稳定,使用几何兜底) + description: 主聊天视图(post_verify 校验用,模板匹配不稳定,使用几何兜底;大区域无需 ROI 限制) # 好友申请通过 UI 元素定位(experimental,坐标为估算值需实测校准) contact_icon: @@ -93,6 +131,11 @@ contact_icon: y_ratio: 0.171 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.0 + y_ratio: 0.05 + w_ratio: 0.10 + h_ratio: 0.30 threshold: 0.80 require_image: false description: 左侧导航栏通讯录图标 @@ -105,6 +148,11 @@ new_friend_entry: y_ratio: 0.138 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.05 + y_ratio: 0.10 + w_ratio: 0.25 + h_ratio: 0.20 threshold: 0.80 require_image: false description: 通讯录页"新的朋友"入口 @@ -116,6 +164,11 @@ friend_request_item: y_ratio: 0.178 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.05 + y_ratio: 0.15 + w_ratio: 0.30 + h_ratio: 0.30 threshold: 0.75 require_image: false description: '"新的朋友"列表第一条申请项(模板匹配不稳定,使用几何兜底)' @@ -128,6 +181,11 @@ verify_button: y_ratio: 0.394 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.40 + y_ratio: 0.20 + w_ratio: 0.40 + h_ratio: 0.40 threshold: 0.75 require_image: false description: 好友详情页"前往验证"按钮(OpenCV 实测标定) @@ -139,6 +197,11 @@ confirm_button: y_ratio: 0.830 x_offset: 0 y_offset: 0 + roi: + x_ratio: 0.30 + y_ratio: 0.65 + w_ratio: 0.40 + h_ratio: 0.30 threshold: 0.75 require_image: false description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)'