WechatOnCloud/bridge/woc_bridge/ui/flow.py

234 lines
8.9 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 回到干净状态
点击位置采用比例坐标固定落在右侧聊天区域避免命中左侧联系人列表
分割线或底部输入框导致的误操作
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()")