WechatOnCloud/bridge/woc_bridge/ui/drivers/base.py
Kris 2348a04163 feat: 完成微信4.0模板UI重构与批量群发、导出功能落地
本次提交包含多项核心更新:
1. 新增微信4.0浅色主题UI模板资源与说明文档,补充了联系人图标、新的朋友入口等四个UI元素的图像匹配模板
2. 重构UI驱动层,将原2375行的单文件拆分为5个职责清晰的子驱动类,提升代码可维护性
3. 新增批量群发消息与聊天记录导出的完整数据模型、路由与后台协程,支持幂等校验与风控配置
4. 为所有业务接口新增post_verify校验逻辑,补充了verified字段返回操作结果
5. 优化媒体文件解密逻辑,修复了导出功能中的数据库错误处理与分辨率硬编码问题
6. 更新版本配置,将媒体发送功能标记为已落地,调整了能力列表与配置项
2026-07-16 16:53:42 +08:00

275 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.

"""UI driver 基类:抽取 _step / _parse_window_geometry / _click_at 公共能力。
设计原则:
- 优先委托 XdotoolBackend若已构造复用其 _ui_lock 串行保护
- legacy 模式自建轻量 _run_managed兼容 ui_backend=legacy 场景)
- 子类继承 BaseDriver 后只需实现业务方法,无需重复造轮子
消除的重复(源自 xdotool_driver.py 单类 2375 行):
- 11 处内联 `_step` 闭包deadline 计算 + max(_STEP_MIN_TIMEOUT_SEC, remaining)
+ asyncio.wait_for 超时包装 + coro.close() 兜底
- 7+ 处 `getwindowgeometry --shell` 输出解析geom = {} + split("=", 1) + int()
- _click_at 链式命令mousemove --sync X Y click 1的重复实现
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from dataclasses import dataclass
from typing import Any, Awaitable, Optional
logger = logging.getLogger("woc-bridge")
# ---------------------------------------------------------------------------
# 分层超时常量(与 backends/xdotool.py / xdotool_driver.py 保持一致)
# L1 _CMD_TIMEOUT_SEC : 单条 xdotool/pgrep/kill 子进程命令超时
# L2 _STEP_MIN_TIMEOUT_SEC : 单个 UI 步骤_step超时下限 = L1 + 2s
# L2 必须 > L1避免 _step 的 wait_for 先于 _run 内的 L1 触发导致子进程
# 未被 kill 而泄漏。
# ---------------------------------------------------------------------------
_CMD_TIMEOUT_SEC = 5.0
_STEP_MIN_TIMEOUT_SEC = 7.0
@dataclass
class WindowGeom:
"""窗口几何信息(对应 xdotool getwindowgeometry --shell 输出)。
与 backends/base.py 的 WindowGeometry 区别:本类额外携带 window_id
供 driver 层在多次操作间复用窗口 ID避免重复 search。
"""
window_id: int
x: int
y: int
width: int
height: int
class BaseDriver:
"""UI driver 抽象基类。
子类WindowDriver / LoginDriver / MessageDriver / MomentDriver /
ContactDriver继承本类自动获得 _step / _parse_window_geometry /
_click_at 能力,无需重复造轮子。
使用方式:
# 优先委托 XdotoolBackend推荐复用其 _ui_lock 串行保护)
backend = XdotoolBackend(display=":1")
driver = MyDriver(display=":1", backend=backend)
# legacy 模式(无 backend自建 _ui_lock + _run_managed
driver = MyDriver(display=":1")
"""
def __init__(
self,
display: str,
backend: Optional[Any] = None,
) -> None:
"""初始化 driver。
Args:
display: X server display 地址,如 ":1"
backend: 可选 XdotoolBackend 实例;提供时复用其 _ui_lock 串行保护
与 click/type/key 等能力None 时走 legacy _run_managed
"""
self._display = display
self._backend = backend
# legacy 模式自建 _ui_lock若 backend 已存在则复用其 _ui_lock
# (确保 driver 与 backend 共享同一把锁,避免并发点击焦点错乱)
if backend is not None and hasattr(backend, "_ui_lock"):
self._ui_lock = backend._ui_lock
else:
self._ui_lock = asyncio.Lock()
# ------------------------------------------------------------------
# 环境与子进程管理
# ------------------------------------------------------------------
def _env(self) -> dict:
"""返回带 DISPLAY 的环境副本。"""
env = dict(os.environ)
env["DISPLAY"] = self._display
return env
async def _run_managed(self, cmd: list[str]) -> tuple[int, bytes, bytes]:
"""legacy 模式执行 xdotool 命令(无 backend 时使用)。
- 持有 _ui_lock 串行保护(与 XdotoolBackend 行为一致)
- asyncio.create_subprocess_exec 异步执行,不阻塞 event loop
- L1 (_CMD_TIMEOUT_SEC) 超时兜底,超时/取消时 kill+wait 收尸,
避免僵尸进程残留占住管道/资源
- 返回 (returncode, stdout, stderr);超时抛 asyncio.TimeoutError
(由调用方包装为 BridgeError
Args:
cmd: 命令及其参数列表,如 ["xdotool", "search", "--name", "微信"]
"""
async with self._ui_lock:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=self._env(),
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=_CMD_TIMEOUT_SEC
)
except (asyncio.TimeoutError, asyncio.CancelledError):
# 先 kill 子进程再收尸,避免僵尸进程残留占住管道/资源
proc.kill()
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
logger.error(
"[ui-driver] proc.wait() 超时,孤儿进程残留: %s",
cmd[0] if cmd else "?",
)
# 抛原始 TimeoutError/CancelledError交由调用方包装
raise
except Exception:
# 兜底:确保任何异常路径下子进程都被回收
if proc.returncode is None:
proc.kill()
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
logger.error(
"[ui-driver] proc.wait() 超时: %s",
cmd[0] if cmd else "?",
)
raise
return (proc.returncode, stdout, stderr)
# ------------------------------------------------------------------
# 公共能力_step / _parse_window_geometry / _click_at
# ------------------------------------------------------------------
async def _step(
self,
coro: Awaitable,
desc: str,
deadline: Optional[float] = None,
) -> Any:
"""通用超时包装方法(替换 11 处内联 _step 闭包)。
语义对齐 xdotool_driver.py 中的内联闭包:
- deadline 为绝对时间戳time.monotonic() 返回值None 时使用
_STEP_MIN_TIMEOUT_SEC 作为固定超时
- 剩余时间 <= 0 时关闭未启动的协程并抛 asyncio.TimeoutError
避免 "coroutine was never awaited" 警告
- 剩余时间 < _STEP_MIN_TIMEOUT_SEC 时取下限max 兜底),
避免 L2 先于 _run 内的 L1 触发导致子进程未被 kill 而泄漏
- 超时抛 asyncio.TimeoutError由调用方包装为 BridgeError
保持基类与 models.BridgeError 解耦)
Args:
coro: 待执行的协程/可等待对象
desc: 步骤描述,用于日志与错误信息
deadline: 绝对超时时间戳time.monotonic() 返回值);
None 时使用 _STEP_MIN_TIMEOUT_SEC
Raises:
asyncio.TimeoutError: 步骤超时或 deadline 已过
"""
if deadline is None:
# 无整体流程 deadline 时,使用 L2 下限作为固定超时
timeout: float = _STEP_MIN_TIMEOUT_SEC
else:
# 必须用 time.monotonic() 与源文件 deadline 语义对齐
# asyncio.get_event_loop().time() 与 time.monotonic() epoch 不同,混用会误判)
remaining = deadline - time.monotonic()
if remaining <= 0:
# deadline 已过:关闭未启动的协程,避免 "never awaited" 警告
# (对齐 xdotool_driver.py L1290-1294 / L1408-1412 的 coro.close() 兜底)
if asyncio.iscoroutine(coro):
coro.close()
logger.warning("[ui-driver] step 已超时deadline 已过): %s", desc)
raise asyncio.TimeoutError()
timeout = max(_STEP_MIN_TIMEOUT_SEC, remaining)
logger.info("[ui-driver] step start: %s", desc)
try:
result = await asyncio.wait_for(coro, timeout=timeout)
except asyncio.TimeoutError:
logger.error("[ui-driver] step timeout: %s (timeout=%.2fs)", desc, timeout)
raise
logger.info("[ui-driver] step done: %s", desc)
return result
@staticmethod
def _parse_window_geometry(output: str) -> Optional[WindowGeom]:
"""解析 `xdotool getwindowgeometry --shell` 输出(替换 7+ 处重复解析)。
输入示例:
WINDOW=12345
X=10
Y=20
WIDTH=800
HEIGHT=600
对齐 xdotool_driver.py 中的内联解析模式:
geom = {}
for line in stdout.decode(errors="ignore").splitlines():
if "=" in line:
k, v = line.split("=", 1)
geom[k.strip()] = v.strip()
Args:
output: getwindowgeometry --shell 的 stdout 文本(已 decode
Returns:
WindowGeom 或 None解析失败时返回 None由调用方决定是否抛错
"""
geom: dict[str, str] = {}
for line in output.splitlines():
if "=" in line:
k, v = line.split("=", 1)
geom[k.strip()] = v.strip()
try:
return WindowGeom(
window_id=int(geom.get("WINDOW", 0)),
x=int(geom.get("X", 0)),
y=int(geom.get("Y", 0)),
width=int(geom.get("WIDTH", 0)),
height=int(geom.get("HEIGHT", 0)),
)
except (ValueError, TypeError):
return None
async def _click_at(self, x: int, y: int) -> None:
"""链式命令点击(替换原 _click_at
优先委托 backend.click(x, y)(若已构造,复用其 _ui_lock 串行保护),
否则走 legacy xdotool 链式命令。
链式命令mousemove --sync X Y click 1单条子进程完成移动+点击,
相比分两条命令可少一次子进程创建与 L1 超时窗口,降低中途超时
导致“鼠标移动到位但未点击”的半成品状态概率。
Args:
x: 目标 X 坐标(绝对坐标)
y: 目标 Y 坐标(绝对坐标)
Raises:
RuntimeError: legacy 模式下 xdotool 命令失败
"""
# 优先委托 backend推荐路径复用其 _ui_lock 与错误处理)
if self._backend is not None and hasattr(self._backend, "click"):
await self._backend.click(x, y)
return
# legacy 模式:单条 xdotool 链式命令完成移动+点击
logger.info("[ui-driver] click at (%d, %d)", x, y)
rc, _, stderr = await self._run_managed(
["xdotool", "mousemove", "--sync", str(x), str(y), "click", "1"]
)
if rc != 0:
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
# 基类不依赖 BridgeError抛 RuntimeError子类可 catch 后包装
raise RuntimeError(f"点击 ({x},{y}) 失败: {err}")
logger.info("[ui-driver] click done rc=%s", rc)