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

311 lines
11 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.

"""L3 Actions 原子动作库find / click / type / wait / screenshot。
Actions 是 L1 Backend 与 L2 Locator 的组合层:给定 kind 与窗口几何,
Locator 解析为 ElementHandle图像优先、几何兜底Backend 据此执行点击/输入。
L4 Flow 通过 Actions 组合出完整业务流程(如 SendTextFlow
策略:
- 图像路径P2 启用sel.by_image 非空且 locator.is_circuited 为 False 且 opencv 非 None 时优先
- 命中record_image_success返回 strategy=image
- 未命中record_image_failurerequire_image=True 抛 ElementNotFoundError否则回退 _geom_find
- 几何兜底P1 默认):按 GeomSpec 计算绝对坐标
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Optional
from woc_bridge.ui.backends.base import BackendProtocol, WindowGeometry
from woc_bridge.ui.locators.base import ElementHandle, Selector
from woc_bridge.ui.locators.registry import LocatorRegistry
logger = logging.getLogger("woc-bridge")
class ElementNotFoundError(Exception):
"""元素未找到(图像匹配失败且 require_image=True或几何规格缺失
TODO: P3 时统一到 errors.py 的 FlowError(code="ELEMENT_NOT_FOUND")。
"""
pass
class Actions:
"""L3 原子动作库:组合 BackendL1与 LocatorL2
所有方法均为 async由 L4 Flow 编排调用。
"""
def __init__(
self, backend: BackendProtocol, locator: LocatorRegistry
) -> None:
"""初始化 Actions。
Args:
backend: L1 Backend 实例(如 XdotoolBackend
locator: L2 LocatorRegistry 实例
"""
self.backend = backend
self.locator = locator
# 截图缓存:同一次 flow 内连续查找可避免重复截图CPU/IO 优化)
self._shot_cache: Optional[tuple[float, bytes]] = None
self._shot_cache_ttl_ms = 150
def _get_cached_screenshot(self) -> Optional[bytes]:
"""返回未过期的缓存截图,若无则 None。"""
if self._shot_cache is None:
return None
ts, shot = self._shot_cache
if (time.monotonic() - ts) * 1000 > self._shot_cache_ttl_ms:
self._shot_cache = None
return None
return shot
def _set_cached_screenshot(self, shot: bytes) -> None:
"""写入截图缓存。"""
self._shot_cache = (time.monotonic(), shot)
# ------------------------------------------------------------------
# 元素查找
# ------------------------------------------------------------------
async def find_element(
self, kind: str, win_geom: WindowGeometry
) -> ElementHandle:
"""查找元素,优先图像匹配,失败回退几何兜底。
Args:
kind: 元素类别键(如 search_box / send_button
win_geom: 当前窗口几何(绝对坐标 + 宽高)
Returns:
ElementHandle命中元素含绝对坐标与策略
Raises:
ElementNotFoundError: require_image=True 且图像匹配失败,
或 by_geom 为空且无图像命中
"""
sel = self.locator.get(kind)
# 图像路径P2 启用P1 阶段 opencv=None 直接跳过)
if (
sel.by_image
and not self.locator.is_circuited(kind)
and self.locator.opencv is not None
):
shot = self._get_cached_screenshot()
if shot is None:
shot = await self.backend.screenshot()
self._set_cached_screenshot(shot)
roi = (win_geom.x, win_geom.y, win_geom.width, win_geom.height)
try:
result = await self.locator.opencv.find_template(
shot,
sel.by_image,
threshold=sel.threshold,
roi=roi,
)
except Exception as exc:
logger.warning(
"[action] opencv.find_template exception kind=%s: %s",
kind,
exc,
)
result = None
if result is not None:
# result = (x, y, w, h, confidence)(绝对坐标,含 ROI 偏移)
x, y, w, h, conf = result
self.locator.record_image_success(kind)
logger.info(
"[action] image match success kind=%s at (%d,%d) conf=%.3f",
kind, x + w // 2, y + h // 2, conf,
)
return ElementHandle(
selector=sel,
x=x + w // 2,
y=y + h // 2,
w=w,
h=h,
confidence=conf,
strategy="image",
)
# 图像未命中
self.locator.record_image_failure(kind)
logger.warning(
"[action] image match failed kind=%s, fall back to geom", kind
)
if sel.require_image:
raise ElementNotFoundError(
f"image_match_failed kind={kind}"
)
logger.debug("[action] geom fallback kind=%s", kind)
return self._geom_find(sel, win_geom)
async def click_element(
self, kind: str, win_geom: WindowGeometry
) -> ElementHandle:
"""查找并点击元素,返回 ElementHandle。
Args:
kind: 元素类别键
win_geom: 当前窗口几何
Returns:
命中的 ElementHandle
"""
elem = await self.find_element(kind, win_geom)
await self.backend.click(elem.x, elem.y)
logger.info(
"[action] click %s at (%d,%d) strategy=%s conf=%.3f",
kind,
elem.x,
elem.y,
elem.strategy,
elem.confidence,
)
return elem
async def type_into(
self,
kind: str,
text: str,
win_geom: WindowGeometry,
clear_first: bool = True,
) -> None:
"""点击元素聚焦后输入文本,可选先清空。
Args:
kind: 元素类别键(通常为 input_box
text: 要输入的文本
win_geom: 当前窗口几何
clear_first: 是否先 BackSpace×30 清空旧内容
"""
t0 = time.perf_counter()
logger.info(
"[action] type_into start kind=%s text_len=%d clear_first=%s",
kind, len(text), clear_first,
)
await self.click_element(kind, win_geom)
if clear_first:
await self.backend.key_press("BackSpace", repeat=30)
await self.backend.type_text(text)
logger.info(
"[action] type_into done kind=%s text_len=%d (%.0fms)",
kind, len(text), (time.perf_counter() - t0) * 1000,
)
async def wait_for(
self,
kind: str,
win_geom: WindowGeometry,
timeout: float = 5.0,
interval: float = 0.3,
) -> Optional[ElementHandle]:
"""轮询等待元素出现,超时返回 None。
Args:
kind: 元素类别键
win_geom: 当前窗口几何
timeout: 最大等待秒数
interval: 轮询间隔秒数
Returns:
ElementHandle 或 None超时
"""
t0 = time.perf_counter()
deadline = time.monotonic() + timeout
attempts = 0
last_err: Optional[Exception] = None
while time.monotonic() < deadline:
attempts += 1
try:
elem = await self.find_element(kind, win_geom)
if elem:
logger.info(
"[action] wait_for OK kind=%s strategy=%s attempts=%d (%.0fms)",
kind, elem.strategy, attempts,
(time.perf_counter() - t0) * 1000,
)
return elem
except ElementNotFoundError as e:
last_err = e
await asyncio.sleep(interval)
logger.warning(
"[action] wait_for TIMEOUT kind=%s attempts=%d timeout=%.1fs last_err=%s",
kind, attempts, timeout, last_err,
)
return None
async def screenshot(self) -> bytes:
"""截图代理方法,带 150ms 缓存。"""
logger.debug("[action] screenshot start")
data = self._get_cached_screenshot()
if data is None:
data = await self.backend.screenshot()
self._set_cached_screenshot(data)
logger.debug(
"[action] screenshot done bytes=%d", len(data) if data else 0
)
return data
# ------------------------------------------------------------------
# 几何兜底
# ------------------------------------------------------------------
def _geom_find(
self, selector: Selector, win_geom: WindowGeometry
) -> ElementHandle:
"""按 GeomSpec 计算绝对坐标,返回 ElementHandle。
支持两种 relative_to 语义:
- window: 以窗口左上角为原点
base_x = win_geom.x + win_geom.width * g.x_ratio + g.x_offset
base_y = win_geom.y + win_geom.height * g.y_ratio + g.y_offset
- window_bottom_right: 以窗口右下角为原点offset 含正负号)
base_x = win_geom.x + win_geom.width + g.x_offset
base_y = win_geom.y + win_geom.height + g.y_offset
Args:
selector: 元素选择器(必须含 by_geom
win_geom: 当前窗口几何
Returns:
ElementHandlestrategy=geom, confidence=1.0
Raises:
ElementNotFoundError: selector.by_geom 为空
"""
if selector.by_geom is None:
raise ElementNotFoundError(
f"no geom spec kind={selector.kind}"
)
g = selector.by_geom
if g.relative_to == "window_bottom_right":
base_x = win_geom.x + win_geom.width + g.x_offset
base_y = win_geom.y + win_geom.height + g.y_offset
else:
base_x = win_geom.x + win_geom.width * g.x_ratio + g.x_offset
base_y = win_geom.y + win_geom.height * g.y_ratio + g.y_offset
logger.debug(
"[action] geom_find kind=%s relative_to=%s win=(%d,%d,%dx%d) "
"ratio=(%.3f,%.3f) offset=(%d,%d) → abs=(%d,%d)",
selector.kind, g.relative_to,
win_geom.x, win_geom.y, win_geom.width, win_geom.height,
g.x_ratio, g.y_ratio, g.x_offset, g.y_offset,
int(base_x), int(base_y),
)
return ElementHandle(
selector=selector,
x=int(base_x),
y=int(base_y),
w=0,
h=0,
confidence=1.0,
strategy="geom",
)