WechatOnCloud/bridge/xdotool_driver.py

613 lines
24 KiB
Python
Raw Normal View History

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