- 新增登录状态守卫后台任务 - 新增好友申请自动通过规则引擎 - 新增多分辨率UI配置与模板资源 - 新增消息拉取复合游标支持 - 优化发送队列与UI自动化逻辑 - 新增批量发送日志与错误处理 - 优化Docker镜像构建与ptrace初始化 - 新增联系人名称缓存预热
311 lines
11 KiB
Python
311 lines
11 KiB
Python
"""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_failure;require_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 原子动作库:组合 Backend(L1)与 Locator(L2)。
|
||
|
||
所有方法均为 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:
|
||
ElementHandle(strategy=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",
|
||
)
|