"""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 状态。 """ from __future__ import annotations import asyncio import logging import random import time from typing import Awaitable, Callable, Optional 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 = "微信" # 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: 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 async def run(self, ctx: FlowContext) -> FlowResult: """执行发送文本流程。""" started_at = time.monotonic() # 后台 task:获取 before_msg_id(与 _activate 并行) before_msg_task: Optional[asyncio.Task] = None try: async with asyncio.timeout(_FLOW_TIMEOUT_SEC): # 检查会话缓存(用 to_wxid 做 key,display_name 非唯一) cache_hit = False if self.session_cache is not None: cached = self.session_cache.get() if cached is not None and cached == ctx.to_wxid: cache_hit = True logger.info( "[flow:send_text] session cache hit for %s", ctx.to_wxid, ) if cache_hit: # 跳过 click_search_box ~ open_session,但仍需激活窗口 # (30s 内用户可能切到其他应用,微信失去焦点) 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 = await self.actions.backend.get_window_geometry( _WECHAT_WINDOW_TITLE ) await self._verify_session_still_open(ctx) ctx.current_state = FlowState.SESSION_OPENED 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 await self._send(ctx) await self._cleanup(ctx) # 成功:更新会话缓存(用 to_wxid 做 key) if self.session_cache is not None and ctx.verified: self.session_cache.set(ctx.to_wxid) 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", ctx.current_state, 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() error_code = "" error_msg = str(exc) if isinstance(exc, FlowError): 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() return FlowResult( success=False, error=str(exc), error_code="BRIDGE_INTERNAL_ERROR", 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 # ------------------------------------------------------------------ async def _activate(self, ctx: FlowContext) -> None: """激活窗口 + 获取几何 + Esc。""" await self.actions.backend.activate_window(_WECHAT_WINDOW_TITLE) ctx.win_geom = await self.actions.backend.get_window_geometry( _WECHAT_WINDOW_TITLE ) await self.actions.backend.key_press("Escape") ctx.current_state = FlowState.WINDOW_ACTIVATED logger.info( "[flow:send_text] activated window geom=%dx%d", ctx.win_geom.width, ctx.win_geom.height, ) async def _click_search_box(self, ctx: FlowContext) -> None: """点击搜索框。""" await self.actions.click_element("search_box", ctx.win_geom) ctx.current_state = FlowState.SEARCH_BOX_CLICKED # 简化:点击后立即可输入,等待 0.3s 让 UI 响应 await asyncio.sleep(0.3) async def _type_query(self, ctx: FlowContext) -> None: """输入搜索查询(display_name)。""" # 清空可能的残留 await self.actions.backend.key_press("BackSpace", repeat=30) await self.actions.backend.type_text(ctx.display_name) ctx.current_state = FlowState.QUERY_TYPED # 自适应等待搜索结果 appeared = await self._wait_for_condition( lambda: self._check_search_result_appeared(ctx), timeout=3.0, interval=0.3, ) if not appeared: logger.warning( "[flow:send_text] search result not appeared in 3s, continue" ) async def _open_session(self, ctx: FlowContext) -> None: """点击搜索结果第一项打开会话。""" await self.actions.click_element("search_result_first", ctx.win_geom) ctx.current_state = FlowState.SESSION_OPENED # 自适应等待 input_box 出现 appeared = await self._wait_for_condition( lambda: self._check_input_box_appeared(ctx), timeout=3.0, interval=0.3, ) if not appeared: logger.warning( "[flow:send_text] input_box not appeared in 3s, continue" ) async def _focus_input(self, ctx: FlowContext) -> None: """聚焦输入框。""" await self.actions.click_element("input_box", ctx.win_geom) ctx.current_state = FlowState.INPUT_FOCUSED async def _type_content(self, ctx: FlowContext) -> None: """输入消息内容。""" await self.actions.backend.type_text(ctx.content) ctx.current_state = FlowState.TEXT_TYPED async def _send(self, ctx: FlowContext) -> None: """点击发送按钮 + 校验。""" 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) logger.info( "[flow:send_text] sent local_id=%s verified=%s", ctx.local_send_id, ctx.verified, ) async def _cleanup(self, ctx: FlowContext) -> None: """清理:Esc。""" await self.actions.backend.key_press("Escape") ctx.current_state = FlowState.CLEANED_UP # ------------------------------------------------------------------ # 校验 # ------------------------------------------------------------------ async def _verify_sent(self, ctx: FlowContext) -> bool: """发送结果校验:DB 校验优先,失败降级截图校验。""" if self.db_reader is None: logger.info("[flow:send_text] no db_reader, fall back to screenshot") return await self._verify_by_screenshot(ctx) # 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, ) return await self._verify_by_screenshot(ctx) 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)", attempts, msg.get("is_sender"), ) return True # 有新消息但内容不匹配 → 可能串号或消息未到达 logger.warning( "[flow:send_text] DB verify: %d new msgs but content not matched (attempt=%d)", len(messages), attempts, ) # 继续轮询,可能是 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) logger.warning( "[flow:send_text] DB verify timeout, fall back to screenshot" ) return await self._verify_by_screenshot(ctx) async def _verify_by_screenshot(self, ctx: FlowContext) -> bool: """截图校验:等待 input_box_empty 模板出现。""" if ctx.win_geom is None: return False try: elem = await self.actions.wait_for( "input_box_empty", ctx.win_geom, timeout=3.0, interval=0.3 ) if elem is not None: logger.info("[flow:send_text] screenshot verify OK") return True except Exception as exc: logger.debug( "[flow:send_text] screenshot verify exception: %s", exc ) logger.warning("[flow:send_text] screenshot verify failed") return False async def _verify_session_still_open(self, ctx: FlowContext) -> None: """会话缓存命中时校验会话仍打开(input_box 存在)。""" # 获取窗口几何(缓存命中时未走 _activate) ctx.win_geom = await self.actions.backend.get_window_geometry( _WECHAT_WINDOW_TITLE ) try: elem = await self.actions.wait_for( "input_box", ctx.win_geom, timeout=2.0, interval=0.3 ) if elem is None: raise PermanentError( "ELEMENT_NOT_FOUND", "session cache hit but input_box not found", ) except PermanentError: raise except Exception as exc: raise PermanentError( "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: """检查搜索结果是否出现。""" try: elem = await self.actions.find_element( "search_result_first", ctx.win_geom ) return elem is not None except Exception: return False async def _check_input_box_appeared(self, ctx: FlowContext) -> bool: """检查输入框是否出现。""" try: elem = await self.actions.find_element("input_box", ctx.win_geom) return elem is not None 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: return (0, 0) 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)) return (ct, lid) except Exception as exc: logger.debug( "[flow:send_text] get_latest_create_time exception: %s", exc ) return (0, 0)