"""L4 SendTextFlow:发送文本消息状态机。 8 个 transition:activate → click_search_box → type_query → open_session → 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 import asyncio import logging import random import time from typing import Optional from woc_bridge.ui.backends.base import WindowGeometry from woc_bridge.ui.errors import FlowError, PermanentError from woc_bridge.ui.flow import ( Flow, FlowContext, FlowResult, FlowState, _FLOW_TIMEOUT_SEC, ) logger = logging.getLogger("woc-bridge") # 微信窗口标题 _WECHAT_WINDOW_TITLE = "微信" class SendTextFlow(Flow): """发送文本消息 Flow。""" 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 self.verify_bus = verify_bus async def run(self, ctx: FlowContext) -> FlowResult: """执行发送文本流程。""" started_at = time.monotonic() logger.info( "[flow:send_text] START to_wxid=%s display_name=%s content_len=%d client_request_id=%s", ctx.to_wxid, ctx.display_name, len(ctx.content), ctx.client_request_id, ) try: async with asyncio.timeout(_FLOW_TIMEOUT_SEC): # 检查会话缓存(用 to_wxid 做 key,display_name 非唯一) cache_hit = False if self.session_cache is not None: cache_hit = self.session_cache.get(ctx.to_wxid) if cache_hit: logger.info( "[flow:send_text] session cache hit for %s", ctx.to_wxid, ) if cache_hit: # 跳过 click_search_box ~ open_session,但仍需激活窗口 # (30s 内用户可能切到其他应用,微信失去焦点) t0 = time.perf_counter() # 仅激活窗口 + 获取几何,跳过 Esc 清场(会话已打开) 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 ) ) # 校验会话仍打开;失败时回退到完整流程,不直接返回失败 try: await self._verify_session_still_open(ctx) ctx.current_state = FlowState.SESSION_OPENED logger.info( "[flow:send_text] cache_hit branch done geom=%dx%d (%.0fms)", ctx.win_geom.width, ctx.win_geom.height, (time.perf_counter() - t0) * 1000, ) except PermanentError as verify_exc: logger.info( "[flow:send_text] cache_hit verify failed, fallback to full flow: %s (%.0fms)", verify_exc, (time.perf_counter() - t0) * 1000, ) # 失效缓存,走完整流程 if self.session_cache is not None: self.session_cache.invalidate(ctx.to_wxid) cache_hit = False if not cache_hit: # 完整流程 await self._activate(ctx) 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) 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) logger.info( "[flow:send_text] DONE success=%s verified=%s local_id=%s state=%s (%.0fms)", True, ctx.verified, ctx.local_send_id, ctx.current_state, (time.monotonic() - started_at) * 1000, ) return FlowResult( success=True, local_id=ctx.local_send_id, verified=ctx.verified, state=ctx.current_state, image_confidence=ctx.image_confidence, duration_ms=(time.monotonic() - started_at) * 1000, ) except (FlowError, asyncio.TimeoutError) as exc: logger.warning( "[flow:send_text] failed at state=%s: %s: %s", ctx.current_state, type(exc).__name__, exc, ) # 尝试恢复 try: await self._reset_to_idle(ctx.win_geom) except Exception as reset_exc: logger.warning( "[flow:send_text] reset_to_idle failed: %s", reset_exc ) # 失败时仅失效当前联系人会话缓存 if self.session_cache is not None: self.session_cache.invalidate(ctx.to_wxid) error_code = "SEND_FAILED" error_msg = str(exc) if isinstance(exc, FlowError) and exc.code: error_code = exc.code elif isinstance(exc, asyncio.TimeoutError): error_code = "SEND_TIMEOUT" return FlowResult( success=False, local_id=ctx.local_send_id, verified=False, error=error_msg, error_code=error_code, state=FlowState.FAILED, duration_ms=(time.monotonic() - started_at) * 1000, ) except Exception as exc: logger.exception("[flow:send_text] unexpected exception") try: await self._reset_to_idle(ctx.win_geom) except Exception: pass if self.session_cache is not None: self.session_cache.invalidate(ctx.to_wxid) return FlowResult( success=False, error=str(exc), error_code="BRIDGE_INTERNAL_ERROR", state=FlowState.FAILED, duration_ms=(time.monotonic() - started_at) * 1000, ) # ------------------------------------------------------------------ # Transitions # ------------------------------------------------------------------ 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 (%.0fms)", ctx.win_geom.width, ctx.win_geom.height, (time.perf_counter() - t0) * 1000, ) async def _click_search_box(self, ctx: FlowContext) -> None: """点击搜索框。""" t0 = time.perf_counter() 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 logger.info( "[flow:send_text] step=search_box click done (%.0fms)", (time.perf_counter() - t0) * 1000, ) async def _type_query(self, ctx: FlowContext) -> None: """输入搜索查询(display_name)。""" t0 = time.perf_counter() logger.info( "[flow:send_text] step=type_query start display_name=%r", ctx.display_name, ) # 清空可能的残留:Ctrl+A 全选 + BackSpace 删除,比 BackSpace×30 更可靠 await self.actions.backend.key_press("ctrl+a") await self.actions.backend.key_press("BackSpace") await asyncio.sleep(0.2) await self.actions.backend.type_text(ctx.display_name) ctx.current_state = FlowState.QUERY_TYPED # 纯坐标模式下无法用 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, ) async def _open_session(self, ctx: FlowContext) -> None: """按 Enter 打开搜索结果第一项(比点击坐标更可靠)。""" t0 = time.perf_counter() 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, ) async def _focus_input(self, ctx: FlowContext) -> None: """聚焦输入框。""" t0 = time.perf_counter() logger.info("[flow:send_text] step=focus_input click input_box start") await self.actions.click_element("input_box", ctx.win_geom) ctx.current_state = FlowState.INPUT_FOCUSED await asyncio.sleep(0.1) logger.info( "[flow:send_text] step=focus_input done (%.0fms)", (time.perf_counter() - t0) * 1000, ) async def _type_content(self, ctx: FlowContext) -> None: """输入消息内容。""" t0 = time.perf_counter() # 1. 清理首尾空白 cleaned_content = ctx.content.strip() # 2. 把换行符(含 \r\n / \n / \r)替换为空格,避免 xdotool type 把 \n # 解析为 Return 键,导致消息在输入过程中被提前分段发送。 # 微信输入框本身支持 Shift+Enter 换行,但 xdotool type 无法区分。 normalized_content = cleaned_content.replace("\r\n", " ").replace("\n", " ").replace("\r", " ") # 3. 合并连续空格为一个,保持内容整洁 normalized_content = " ".join(normalized_content.split()) if normalized_content != ctx.content: logger.info( "[flow:send_text] step=type_content normalized: " "original_len=%d cleaned_len=%d", len(ctx.content), len(normalized_content), ) ctx.content = normalized_content logger.info( "[flow:send_text] step=type_content start content_len=%d", len(ctx.content), ) # 4. 输入前清空输入框,避免上次残留内容与本次内容混合 await self.actions.backend.key_press("ctrl+a") await self.actions.backend.key_press("Delete") await asyncio.sleep(0.05) await self.actions.backend.type_text(ctx.content) ctx.current_state = FlowState.TEXT_TYPED logger.info( "[flow:send_text] step=type_content done (%.0fms)", (time.perf_counter() - t0) * 1000, ) 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() 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)}" # 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, (time.perf_counter() - t0) * 1000, ) async def _cleanup(self, ctx: FlowContext) -> None: """清理:Esc。""" t0 = time.perf_counter() logger.info("[flow:send_text] step=cleanup Esc start") await self.actions.backend.key_press("Escape") await asyncio.sleep(0.2) ctx.current_state = FlowState.CLEANED_UP logger.info( "[flow:send_text] step=cleanup done (%.0fms)", (time.perf_counter() - t0) * 1000, ) # ------------------------------------------------------------------ # 校验:SSE 推送匹配 # ------------------------------------------------------------------ async def _wait_verify(self, future: Optional[asyncio.Future], ctx: FlowContext) -> bool: """等待 SSEVerifyBus 的 future 完成。 future 由 _send 在触发发送前 register,确保 MessageStreamer 广播消息时 notify 能匹配到 waiter。匹配到返回 True,超时返回 False。 无 DB 轮询、无截图校验、无熔断器,避免雪崩。 future 为 None 时(verify_bus 未注入)直接返回 False,不阻塞发送。 """ if future is None: logger.warning( "[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 async def _verify_session_still_open(self, ctx: FlowContext) -> None: """会话缓存命中时校验会话仍打开。 纯坐标模式(WOC_DISABLE_OPENCV=1)下 wait_for("input_box") 纯算术 返回,无法真正校验 UI 状态。此时改用 is_window_active 校验窗口焦点: 若微信窗口已失焦(用户切到其他应用),cache 失效走完整流程。 会话是否真的打开仍靠后续 SSE verify 兜底(content 不匹配则 invalidate)。 OpenCV 模式下保留原逻辑(wait_for input_box 模板匹配)。 异常处理:get_window_geometry 的 RuntimeError(窗口消失)也转为 PermanentError,触发 cache_hit 回退到完整流程,避免冒泡为 BRIDGE_INTERNAL_ERROR。 """ t0 = time.perf_counter() # 获取窗口几何(缓存命中时未走 _activate),并校验/窄化类型 try: ctx.win_geom = self._ensure_valid_geometry( await self.actions.backend.get_window_geometry(_WECHAT_WINDOW_TITLE) ) except (RuntimeError, PermanentError) as exc: logger.warning( "[flow:send_text] session_still_open FAIL: get_window_geometry failed: %s (%.0fms)", exc, (time.perf_counter() - t0) * 1000, ) raise PermanentError( "WINDOW_NOT_FOUND", f"session cache verify failed: window not found: {exc}", ) from woc_bridge.ui.actions import _DISABLE_OPENCV if _DISABLE_OPENCV: # 纯坐标模式:校验窗口焦点,避免命中已失焦的会话 is_active = await self.actions.backend.is_window_active( _WECHAT_WINDOW_TITLE ) elapsed_ms = (time.perf_counter() - t0) * 1000 if not is_active: logger.warning( "[flow:send_text] session_still_open FAIL: window not active (%.0fms)", elapsed_ms, ) raise PermanentError( "ELEMENT_NOT_FOUND", "session cache verify failed: window not active", ) logger.info( "[flow:send_text] session_still_open OK (window active, %.0fms)", elapsed_ms, ) return # OpenCV 模式:用 input_box 模板匹配校验 try: elem = await self.actions.wait_for( "input_box", ctx.win_geom, timeout=2.0, interval=0.3 ) if elem is None: logger.warning( "[flow:send_text] session_still_open FAIL: input_box not found (%.0fms)", (time.perf_counter() - t0) * 1000, ) raise PermanentError( "ELEMENT_NOT_FOUND", "session cache verify failed: input_box not found", ) logger.info( "[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)", exc, (time.perf_counter() - t0) * 1000, ) raise PermanentError( "ELEMENT_NOT_FOUND", f"session cache verify failed: {exc}", )