WechatOnCloud/bridge/xdotool_driver.py
Kris a2201d8be3 feat: 新增woc-bridge服务及配套能力
新增完整的woc-bridge业务服务,实现微信API代理、密钥自动提取、消息队列限流等功能:
1. 新增bridge核心代码与s6服务配置
2. 新增ptrace权限初始化脚本与docker配置
3. 新增M2M鉴权API代理与环境变量配置
4. 新增SQLCipher解密、密钥缓存、二维码截图等工具模块
5. 完善docker构建与实例部署配置
2026-07-07 19:04:13 +08:00

613 lines
24 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.

"""xdotool / xclip 异步驱动。
封装对微信窗口的所有 X11 自动化操作:查找窗口、检测登录态、激活窗口、
通过 Ctrl+F 搜索会话并粘贴发送文本。所有外部命令通过 asyncio 子进程执行,
不阻塞 event loop。
"""
from __future__ import annotations
import asyncio
import base64
import logging
import os
import random
import time
from typing import Optional
from models import BridgeError, LoginState
# 模块级 logger与 server.py 同名,便于 s6 统一收日志
logger = logging.getLogger("woc-bridge")
class XdotoolDriver:
"""xdotool/xclip 异步驱动。
所有方法均为 async内部使用 asyncio.create_subprocess_exec 调用
xdotool / xclip / pgrep 等命令,并通过 DISPLAY 环境变量指定 X server。
"""
def __init__(self, display: str = ":1") -> None:
"""保存 DISPLAY 环境变量值。
Args:
display: X server display 地址,如 ":1"
"""
self.display = display
# ------------------------------------------------------------------
# 环境与底层工具
# ------------------------------------------------------------------
def _env(self) -> dict:
"""返回带 DISPLAY 的环境副本。"""
env = dict(os.environ)
env["DISPLAY"] = self.display
return env
async def _run(
self,
args: list[str],
*,
input_bytes: Optional[bytes] = None,
) -> tuple[int, bytes, bytes]:
"""执行一条命令并返回 (returncode, stdout, stderr)。
Args:
args: 命令及其参数列表,如 ["xdotool", "search", "--name", "微信"]
input_bytes: 需要写入 stdin 的字节
"""
proc = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE if input_bytes is not None else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=self._env(),
)
stdout, stderr = await proc.communicate(input=input_bytes)
return proc.returncode, stdout, stderr
async def _key(self, key: str) -> None:
"""执行 xdotool key <key>。"""
await self._run(["xdotool", "key", key])
async def _sleep(self, seconds: float) -> None:
"""asyncio.sleep 封装,便于测试与统一调速。"""
await asyncio.sleep(seconds)
# ------------------------------------------------------------------
# 窗口与进程检测
# ------------------------------------------------------------------
async def find_wechat_window(self) -> Optional[int]:
"""用 xdotool search --name "微信" 查找微信窗口 ID。
Returns:
窗口 IDint找不到返回 None
"""
returncode, stdout, stderr = await self._run(
["xdotool", "search", "--name", "微信"]
)
if returncode != 0:
return None
text = stdout.decode(errors="ignore").strip()
if not text:
return None
# 取第一个匹配的窗口 ID
first_line = text.splitlines()[0].strip()
try:
return int(first_line)
except ValueError:
return None
async def is_wechat_running(self) -> bool:
"""判断微信进程是否运行。
用 pgrep -x 精确匹配进程名 wechat避免 -f 子串匹配误中
bridge 自身命令行里的 /config/.config/xwechat 路径)。
若 pgrep 不可用,回落到 xdotool search窗口存在即视为进程运行
"""
# 用 -x 精确进程名匹配,避免 -f 子串误中 bridge 自身
# 微信 4.0 Linux 进程名通常为 "wechat"
for name in ("wechat", "WeChat"):
returncode, stdout, _ = await self._run(["pgrep", "-x", name])
if returncode == 0 and stdout.decode(errors="ignore").strip():
return True
# 回落:窗口存在即视为运行
window_id = await self.find_wechat_window()
return window_id is not None
async def activate_window(self, window_id: Optional[int] = None) -> None:
"""激活微信窗口。
Args:
window_id: 指定窗口 ID为 None 时自动查找
"""
if window_id is None:
window_id = await self.find_wechat_window()
if window_id is None:
raise BridgeError(
code="WINDOW_NOT_FOUND",
message="未找到微信窗口,无法激活",
)
await self._run(["xdotool", "windowactivate", "--sync", str(window_id)])
async def _activate_window_fast(self, window_id: Optional[int] = None) -> None:
"""非阻塞激活微信窗口,避免 --sync 在 VNC 无人操作时死等。"""
if window_id is None:
window_id = await self.find_wechat_window()
if window_id is None:
raise BridgeError(
code="WINDOW_NOT_FOUND",
message="未找到微信窗口,无法激活",
)
await self._run(["xdotool", "windowactivate", str(window_id)])
async def restart_wechat(self, timeout_sec: int = 30) -> int:
"""重启微信进程:发 SIGTERM 让微信优雅退出,等待 autostart 拉起新进程。
微信由容器内 autostart 脚本常驻拉起while true 循环 + sleep 2
故 bridge 只需 killautostart 会在 2 秒后自动重启。不强制 SIGKILL
给微信优雅退出机会(避免 DB 写入未刷盘)。
Args:
timeout_sec: 等待新进程出现的最大秒数,默认 30
Returns:
新进程的 PID
Raises:
BridgeError(RESTART_TIMEOUT): 超时未检测到新进程
"""
# 1. 获取当前 PID若有
returncode, stdout, _ = await self._run(["pgrep", "-x", "wechat"])
old_pids: set[int] = set()
if returncode == 0:
for line in stdout.decode(errors="ignore").splitlines():
line = line.strip()
if line:
try:
old_pids.add(int(line))
except ValueError:
pass
# 2. 若有旧进程,发 SIGTERM
for pid in old_pids:
# 用 killshell 内建)发 SIGTERM不强制 SIGKILL
await self._run(["kill", "-TERM", str(pid)])
# 3. 等待新进程出现autostart 会在 2 秒后拉起)
deadline = time.monotonic() + timeout_sec
while time.monotonic() < deadline:
await self._sleep(1.0)
rc, stdout, _ = await self._run(["pgrep", "-x", "wechat"])
if rc == 0:
for line in stdout.decode(errors="ignore").splitlines():
line = line.strip()
if not line:
continue
try:
new_pid = int(line)
except ValueError:
continue
# 确认是新 PID不在旧 PID 集合中)
if new_pid not in old_pids:
return new_pid
# 4. 超时
raise BridgeError(
code="RESTART_TIMEOUT",
message=f"等待微信重启超时({timeout_sec}s",
)
async def detect_login_state(self) -> str:
"""检测登录态(启发式)。
MVP 阶段判定逻辑:
- 微信进程未运行 → not_running
- 进程运行但窗口未找到 → not_logged_in
- 窗口存在 → logged_in
Returns:
LoginState 枚举值(字符串)
"""
running = await self.is_wechat_running()
if not running:
return LoginState.NOT_RUNNING.value
window_id = await self.find_wechat_window()
if window_id is None:
return LoginState.NOT_LOGGED_IN.value
# MVP窗口存在即视为已登录
return LoginState.LOGGED_IN.value
# ------------------------------------------------------------------
# 显式启动微信(供 diagnostic autofix 使用)
# ------------------------------------------------------------------
# 微信可执行文件固定路径(与 docker/app-defs.sh 中 wechat 类型一致)。
# 仅对 wechat 类型实例有意义Telegram/Chromium/自定义应用的生命周期
# 不应通过 bridge autofix 干预。
_WECHAT_BIN = "/config/wechat/opt/wechat/wechat"
async def start_wechat(self, timeout_sec: int = 10) -> Optional[int]:
"""显式启动微信进程,返回新 PID 或 None。
用于 diagnostic autofixpkill 后 autostart watchdog 未拉起时,
bridge 显式启动作为兜底。避免引入 shell 依赖,直接执行二进制。
竞态规避:
1. 启动前再次 pgrep已有进程则直接返回不重复启动
2. 启动后轮询 pgrep等待新 PID 出现
Args:
timeout_sec: 等待新进程出现的最大秒数,默认 10
Returns:
新进程 PID启动失败或超时返回 None
"""
# 1. 启动前再 pgrep已有则跳过避免与 autostart 竞态)
existing_pid = await self.check_wechat_pid()
if existing_pid is not None:
return existing_pid
# 2. 显式启动disown 语义:不等待进程结束)
# DISPLAY/XAUTHORITY 由 _env() 透传,确保 wechat 能连接 X
try:
await asyncio.create_subprocess_exec(
self._WECHAT_BIN,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
env=self._env(),
# 启动后立即 detach不让 wechat 成为 bridge 的子进程
# (避免 bridge 退出时连带杀掉 wechat
start_new_session=True,
)
except Exception as e:
logger.warning("start_wechat: 启动 %s 失败: %r", self._WECHAT_BIN, e)
return None
# 3. 等待新 PID 出现(不依赖 subprocess 返回的 pid因为 wechat
# 可能 fork 出独立进程pgrep 比依赖 proc.pid 更可靠)
deadline = time.monotonic() + timeout_sec
while time.monotonic() < deadline:
await self._sleep(1.0)
new_pid = await self.check_wechat_pid()
if new_pid is not None:
return new_pid
logger.warning("start_wechat: 等待 %ss 内未检测到新进程", timeout_sec)
return None
async def check_wechat_pid(self) -> Optional[int]:
"""返回当前运行的微信主进程 PID无则返回 None。"""
returncode, stdout, _ = await self._run(["pgrep", "-x", "wechat"])
if returncode != 0:
return None
for line in stdout.decode(errors="ignore").splitlines():
line = line.strip()
if not line:
continue
try:
return int(line)
except ValueError:
continue
return None
# ------------------------------------------------------------------
# 剪贴板粘贴
# ------------------------------------------------------------------
async def _paste_via_xclip(self, text: str) -> None:
"""通过 xclip 写入剪贴板并触发 Ctrl+V 粘贴。
直接把原始字节写入 xclip stdincreate_subprocess_exec 不经 shell
无转义问题,因此无需 base64 编码)。并行等待 stdin 写入与进程退出,
避免大文本时管道缓冲阻塞导致的死锁。
Args:
text: 待粘贴文本
Raises:
BridgeError(SEND_FAILED): xclip 退出码非 0
"""
xclip_proc = await asyncio.create_subprocess_exec(
"xclip", "-selection", "clipboard",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
env=self._env(),
)
assert xclip_proc.stdin is not None
async def _feed() -> None:
try:
xclip_proc.stdin.write(text.encode("utf-8"))
await xclip_proc.stdin.drain()
except (BrokenPipeError, ConnectionResetError):
# xclip 已退出,忽略
pass
finally:
try:
xclip_proc.stdin.close()
except Exception:
pass
# 并行:喂 stdin + 等进程退出,避免管道缓冲满死锁
_, (_, stderr) = await asyncio.gather(_feed(), xclip_proc.communicate())
if xclip_proc.returncode != 0:
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
raise BridgeError(
code="SEND_FAILED",
message=f"xclip 写入剪贴板失败 (code={xclip_proc.returncode}): {err}",
)
# 触发粘贴
await self._key("ctrl+v")
# ------------------------------------------------------------------
# 会话定位Ctrl+F 搜索)
# ------------------------------------------------------------------
async def _open_session_by_name(
self,
name: str,
timeout_sec: float = 10.0,
) -> None:
"""通过微信搜索框定位并进入指定联系人的会话。
流程:
1. 非阻塞激活微信窗口
2. 按 Esc 关闭可能存在的搜索框/弹窗
3. Ctrl+F 打开搜索
4. Ctrl+A 全选旧内容,粘贴 display_name
5. 等待搜索结果渲染
6. 按 ↓ 选中第一个结果,回车进入会话
7. 再按 Esc 确保退出搜索模式,焦点落在聊天输入框
Args:
name: 用于搜索的显示名(备注/昵称/微信号)
timeout_sec: 整体超时(秒),默认 10 秒
Raises:
BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口
BridgeError(SEND_FAILED): 超时或 xdotool/xclip 操作失败
"""
if not name:
raise BridgeError(
code="SEND_FAILED",
message="display_name 不能为空,无法定位会话",
)
deadline = time.monotonic() + timeout_sec
async def _step(coro: Awaitable[None], desc: str) -> None:
"""执行单个 UI 步骤并加超时保护。"""
remaining = deadline - time.monotonic()
if remaining <= 0:
raise BridgeError(
code="SEND_FAILED",
message=f"定位会话超时: {desc}",
)
try:
await asyncio.wait_for(coro, timeout=max(1.0, remaining))
except asyncio.TimeoutError as exc:
raise BridgeError(
code="SEND_FAILED",
message=f"定位会话步骤超时: {desc}",
) from exc
# 1. 激活窗口(非阻塞,避免 --sync 死等)
window_id = await self.find_wechat_window()
if window_id is None:
raise BridgeError(
code="WINDOW_NOT_FOUND",
message="未找到微信窗口,无法定位会话",
)
await _step(self._activate_window_fast(window_id), "激活窗口")
await _step(self._sleep(0.3), "等待窗口激活")
# 2. 关闭可能存在的搜索框/弹窗
await _step(self._key("Escape"), "关闭搜索框")
await _step(self._sleep(0.2), "等待 Esc 生效")
# 3. 打开搜索
await _step(self._key("ctrl+f"), "打开搜索")
await _step(self._sleep(0.4), "等待搜索框打开")
# 4. 清空并输入搜索关键词
await _step(self._key("ctrl+a"), "全选搜索框内容")
await _step(self._sleep(0.1), "等待全选")
await _step(self._paste_via_xclip(name), "粘贴搜索关键词")
await _step(self._sleep(0.8), "等待搜索结果")
# 5. 选中第一个结果并进入会话
await _step(self._key("Down"), "选中搜索结果")
await _step(self._sleep(0.3), "等待选中")
await _step(self._key("Return"), "进入会话")
await _step(self._sleep(0.6), "等待会话打开")
# 6. 退出搜索模式,确保焦点在输入框
await _step(self._key("Escape"), "退出搜索模式")
await _step(self._sleep(0.2), "等待焦点稳定")
# ------------------------------------------------------------------
# 发送文本
# ------------------------------------------------------------------
async def send_text(
self,
to_wxid: str,
content: str,
display_name: Optional[str] = None,
) -> str:
"""发送文本消息。
流程:
1. 通过微信搜索框定位会话(使用 display_name未提供时回退到 to_wxid
2. 粘贴文本内容并回车发送
3. 返回本地生成的 channel_msg_id
Args:
to_wxid: 目标 wxid
content: 文本内容
display_name: 用于搜索定位会话的显示名(备注/昵称/微信号)
Returns:
本地生成的 channel_msg_id格式 local_<unix秒>_<随机>
"""
# 1. 定位会话
await self._open_session_by_name(display_name if display_name else to_wxid)
# 2. 粘贴内容并发送
await self._paste_via_xclip(content)
await self._sleep(0.2)
await self._key("Return")
# 3. 生成 local_send_id本地 ID非微信原生 msg_id
local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
return local_send_id
# ------------------------------------------------------------------
# 发送文件 / 图片
# ------------------------------------------------------------------
async def send_file(
self,
to_wxid: str,
file_path: str,
is_image: bool = False,
display_name: Optional[str] = None,
) -> str:
"""发送文件/图片MVP 简化版)。
MVP 策略:检查文件可读 → 进入会话(复用 _open_session_by_name
→ 发送"[图片/文件] 文件名"提示文本。真实文件传输机制留给后续完善。
Args:
to_wxid: 目标 wxid
file_path: 容器内文件绝对路径
is_image: 是否为图片
display_name: 用于搜索定位会话的显示名
Returns:
本地生成的 channel_msg_id格式 local_<unix秒>_<随机>
Raises:
BridgeError(INVALID_PARAMS): 文件不存在或不可读
"""
# 文件存在性校验
if not file_path or not os.path.isfile(file_path):
raise BridgeError(
code="INVALID_PARAMS",
message=f"文件不存在: {file_path}",
)
if not os.access(file_path, os.R_OK):
raise BridgeError(
code="INVALID_PARAMS",
message=f"文件不可读: {file_path}",
)
# 1. 定位会话
await self._open_session_by_name(display_name if display_name else to_wxid)
# 2. MVP发送文件路径提示文本真实文件传输留给后续完善
filename = os.path.basename(file_path)
prefix = "[图片]" if is_image else "[文件]"
hint = f"{prefix} {filename}"
await self._paste_via_xclip(hint)
await self._sleep(0.2)
await self._key("Return")
# 3. 生成 local_send_id本地 ID非微信原生 msg_id
local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
return local_send_id
# ------------------------------------------------------------------
# 退出登录
# ------------------------------------------------------------------
async def logout(self) -> None:
"""通过 UI 操作退出微信登录。
流程:
1. 检测登录态,若非 logged_in 直接返回(幂等)
2. 查找并激活微信窗口(复用 window_id
3. 获取窗口几何,点击左下角主菜单图标
4. 等待菜单弹出,用方向键导航到「退出登录」并回车
5. 若出现确认对话框,按回车确认
6. 等待 2 秒让 UI 完成切换
注意:下方点击与方向键坐标/次数均为估算值,需在目标分辨率
实测后调优(微信 4.0 Linux UI 路径见模块 docstring
Raises:
BridgeError(LOGOUT_FAILED): 窗口未找到或 UI 操作失败
"""
# 1. 检测登录态(幂等)
state = await self.detect_login_state()
if state != LoginState.LOGGED_IN.value:
# 未登录,无需退出
return
# 2. 查找窗口 ID 并激活(复用 window_id避免二次 search
window_id = await self.find_wechat_window()
if window_id is None:
raise BridgeError(
code="LOGOUT_FAILED",
message="未找到微信窗口,无法退出登录",
)
await self.activate_window(window_id)
# 3. 获取窗口几何(用已知的 window_id避免命令链歧义
returncode, stdout, _ = await self._run(
["xdotool", "getwindowgeometry", "--shell", str(window_id)]
)
if returncode != 0:
raise BridgeError(
code="LOGOUT_FAILED",
message="无法获取微信窗口几何信息",
)
# 解析 getwindowgeometry --shell 输出KEY=VALUE 格式)
geom = {}
for line in stdout.decode(errors="ignore").splitlines():
if "=" in line:
k, v = line.split("=", 1)
geom[k.strip()] = v.strip()
try:
win_x = int(geom.get("X", 0))
win_y = int(geom.get("Y", 0))
win_w = int(geom.get("WIDTH", 800))
win_h = int(geom.get("HEIGHT", 600))
except ValueError:
raise BridgeError(
code="LOGOUT_FAILED",
message="解析窗口几何信息失败",
)
# 4. 点击左下角主菜单图标
# 估算:窗口左侧栏底部,约 x=win_x+30, y=win_y+win_h-30
# 该坐标随分辨率/缩放变化,需实测调优。
menu_x = win_x + 30
menu_y = win_y + win_h - 30
rc, _, _ = await self._run(
["xdotool", "mousemove", "--sync", str(menu_x), str(menu_y)]
)
if rc != 0:
raise BridgeError(code="LOGOUT_FAILED", message="移动鼠标到主菜单失败")
# 用 xdotool click 1左键单击
rc, _, _ = await self._run(["xdotool", "click", "1"])
if rc != 0:
raise BridgeError(code="LOGOUT_FAILED", message="点击主菜单失败")
# 5. 等待菜单弹出
await self._sleep(0.8)
# 6. 用方向键导航到「退出登录」并回车
# 微信菜单项顺序通常为:设置 / 切换账号 / 退出登录 / 关闭
# 退出登录一般在第 3 项,从顶部按 ↓ 2 次到达
# 该顺序为估算,需实测调优。
await self._key("Down")
await self._sleep(0.2)
await self._key("Down")
await self._sleep(0.2)
await self._key("Return")
# 7. 等待确认对话框
await self._sleep(0.8)
# 按回车确认(默认焦点通常在确认按钮)
await self._key("Return")
# 8. 等待 UI 完成切换
await self._sleep(2.0)