"""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 。""" 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: 窗口 ID(int),找不到返回 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 只需 kill,autostart 会在 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: # 用 kill(shell 内建)发 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 autofix:pkill 后 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 stdin(create_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__<随机> """ # 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__<随机> 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)