WechatOnCloud/bridge/woc_bridge/ui/flow.py

212 lines
7.3 KiB
Python
Raw Normal View History

"""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 回到干净状态
不依赖 win_geom点击空白用固定坐标避免 win_geom=None 时崩溃
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.3)
# 2. 点击主视图空白区域(避免焦点在搜索框/输入框)
if win_geom is not None:
# 点击主视图中心区域
blank_x = win_geom.x + win_geom.width // 2
blank_y = win_geom.y + win_geom.height // 2
await self.actions.backend.click(blank_x, blank_y)
await asyncio.sleep(0.3)
# 3. BackSpace×30 清空可能残留的输入框内容
await self.actions.backend.key_press("BackSpace", repeat=30)
await asyncio.sleep(0.2)
# 4. Esc×2 再次关闭
await self.actions.backend.key_press("Escape", repeat=2)
await asyncio.sleep(0.2)
# 5. 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()")