WechatOnCloud/bridge/woc_bridge/ui/flow.py
Kris c45282f094 feat: 新增好友自动通过、UI自动化能力与多分辨率适配
- 新增登录状态守卫后台任务
- 新增好友申请自动通过规则引擎
- 新增多分辨率UI配置与模板资源
- 新增消息拉取复合游标支持
- 优化发送队列与UI自动化逻辑
- 新增批量发送日志与错误处理
- 优化Docker镜像构建与ptrace初始化
- 新增联系人名称缓存预热
2026-07-17 18:10:31 +08:00

234 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()")