"""L4 Flow 基类与状态机基础设施。 Flow 是 L4 层的核心抽象:编排 L3 Actions 完成完整业务流程(如发送文本、 发送文件、发布朋友圈)。每个 Flow 由若干 transition 组成,每个 transition 对应一个状态转换。 状态机(9 个状态): INITIAL → WINDOW_ACTIVATED → SEARCH_BOX_CLICKED → QUERY_TYPED → SESSION_OPENED → INPUT_FOCUSED → TEXT_TYPED → SENT → CLEANED_UP 任何状态失败 → FAILED 异常处理:FlowError / TimeoutError 时调 _reset_to_idle 恢复 UI 状态, 避免残留脏状态影响下次流程。 """ from __future__ import annotations import asyncio import enum import logging import time from dataclasses import dataclass, field from typing import Optional from woc_bridge.ui.backends.base import WindowGeometry from woc_bridge.ui.errors import FlowError logger = logging.getLogger("woc-bridge") # 整流程超时(与 backends/xdotool.py 的 _FLOW_TIMEOUT_SEC 对齐) _FLOW_TIMEOUT_SEC = 30.0 class FlowState(enum.Enum): """Flow 状态机 9 个状态。""" INITIAL = "initial" WINDOW_ACTIVATED = "window_activated" SEARCH_BOX_CLICKED = "search_box_clicked" QUERY_TYPED = "query_typed" SESSION_OPENED = "session_opened" INPUT_FOCUSED = "input_focused" TEXT_TYPED = "text_typed" SENT = "sent" CLEANED_UP = "cleaned_up" FAILED = "failed" @dataclass class FlowContext: """Flow 执行上下文(贯穿整个流程,各 transition 共享)。 所有字段均有默认值,避免构造时必填字段顺序问题。 """ to_wxid: str = "" content: str = "" 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 # 内部状态 current_state: FlowState = FlowState.INITIAL started_at: float = field(default_factory=time.monotonic) @dataclass class FlowResult: """Flow 执行结果。""" success: bool = False local_id: str = "" verified: bool = False skipped: bool = False error: str = "" error_code: str = "" state: FlowState = FlowState.INITIAL image_confidence: float = 0.0 duration_ms: float = 0.0 details: Optional[dict] = None class Flow: """L4 Flow 基类。 子类需实现 run(ctx) 方法,编排各 transition。 基类提供 _reset_to_idle / _verify_main_view / _full_cleanup_on_startup 等通用恢复与校验方法。 """ def __init__(self, actions, db_reader=None) -> None: """初始化 Flow。 Args: actions: L3 Actions 实例 db_reader: DB 读取器(可选,用于校验) """ self.actions = actions self.db_reader = db_reader # ------------------------------------------------------------------ # 状态恢复 # ------------------------------------------------------------------ async def _reset_to_idle(self, win_geom: Optional[WindowGeometry] = None) -> bool: """多策略清场:Esc×3 + 点击安全空白 + BackSpace×30 + Esc×2 + 校验。 Flow 失败或启动时调用,确保 UI 回到干净状态。 点击位置采用比例坐标,固定落在右侧聊天区域,避免命中左侧联系人列表、 分割线或底部输入框导致的误操作。 Args: win_geom: 窗口几何(可选,用于点击空白区域与 post_verify) Returns: True 表示清场成功(post_verify 通过或无需校验),False 表示可能仍残留脏状态 """ try: # 1. Esc×3 关闭可能的弹窗/搜索框/联系人详情 await self.actions.backend.key_press("Escape", repeat=3) await asyncio.sleep(0.2) # 2. 点击右侧聊天区域的安全空白处(避开左侧联系人面板与底部输入区) if win_geom is not None and win_geom.width >= 200 and win_geom.height >= 200: # 左侧联系人面板一般占宽度 30% 以内,右侧安全区取 65%-85% # 顶部工具栏约 8%,底部输入区约 15%-20%,安全 y 取 25%-45% safe_x_ratio = 0.75 safe_y_ratio = 0.40 blank_x = win_geom.x + int(win_geom.width * safe_x_ratio) blank_y = win_geom.y + int(win_geom.height * safe_y_ratio) # 兜底保护:若计算点仍落在左侧 42% 区域内,则强制右移 left_panel_max_x = win_geom.x + int(win_geom.width * 0.42) if blank_x <= left_panel_max_x: blank_x = win_geom.x + int(win_geom.width * 0.75) await self.actions.backend.click(blank_x, blank_y) logger.debug( "[flow] _reset_to_idle click safe blank (%d,%d) ratio=(%.2f,%.2f)", blank_x, blank_y, safe_x_ratio, safe_y_ratio, ) await asyncio.sleep(0.2) # 3. BackSpace×30 清空可能残留的输入框/搜索框内容 await self.actions.backend.key_press("BackSpace", repeat=30) await asyncio.sleep(0.15) # 4. Esc×2 再次关闭可能因 BackSpace 触发的弹窗 await self.actions.backend.key_press("Escape", repeat=2) await asyncio.sleep(0.15) # 5. 再次点击安全空白,确保焦点不在输入框 if win_geom is not None and win_geom.width >= 200 and win_geom.height >= 200: blank_x = win_geom.x + int(win_geom.width * 0.75) blank_y = win_geom.y + int(win_geom.height * 0.40) left_panel_max_x = win_geom.x + int(win_geom.width * 0.42) if blank_x <= left_panel_max_x: blank_x = win_geom.x + int(win_geom.width * 0.75) await self.actions.backend.click(blank_x, blank_y) await asyncio.sleep(0.15) # 6. post_verify:尝试用 main_view 模板校验 if win_geom is not None: try: elem = await self.actions.wait_for( "main_view", win_geom, timeout=2.0, interval=0.3 ) if elem is not None: logger.info("[flow] _reset_to_idle post_verify OK") return True logger.warning("[flow] _reset_to_idle post_verify: main_view not found") return False except Exception as exc: logger.warning( "[flow] _reset_to_idle post_verify exception: %s", exc ) return False logger.info("[flow] _reset_to_idle done (no post_verify, win_geom=None)") return True except Exception as exc: logger.warning("[flow] _reset_to_idle exception: %s", exc) return False async def _verify_main_view(self, win_geom: WindowGeometry) -> bool: """校验当前是否在主视图(用 main_view 模板)。 Args: win_geom: 窗口几何 Returns: True 表示在主视图 """ try: elem = await self.actions.wait_for( "main_view", win_geom, timeout=3.0, interval=0.3 ) return elem is not None except Exception: return False async def _full_cleanup_on_startup(self) -> bool: """启动时全量清场:3 次重试 _reset_to_idle。 上次崩溃可能残留脏状态(搜索框打开/输入框有内容), 启动时调用此方法确保干净起点。 Returns: True 表示清场成功,False 表示 3 次都失败(可能需人工 VNC 接入) """ for attempt in range(3): logger.info( "[flow] startup cleanup attempt %d/3", attempt + 1 ) try: # 启动时无 win_geom,用 None(跳过 post_verify) ok = await self._reset_to_idle(win_geom=None) if ok: logger.info( "[flow] startup cleanup OK at attempt %d", attempt + 1 ) return True except Exception as exc: logger.warning( "[flow] startup cleanup attempt %d exception: %s", attempt + 1, exc, ) await asyncio.sleep(1.0) logger.warning("[flow] startup cleanup failed 3 attempts") return False # ------------------------------------------------------------------ # 子类实现 # ------------------------------------------------------------------ async def run(self, ctx: FlowContext) -> FlowResult: """子类实现:编排各 transition,返回 FlowResult。""" raise NotImplementedError("subclass must implement run()")