"""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 Awaitable, Optional from woc_bridge.models import BridgeError, LoginState # 模块级 logger:与 server.py 同名,便于 s6 统一收日志 logger = logging.getLogger("woc-bridge") # 单条 xdotool/pgrep/kill 子进程命令的整体超时(秒)。 # 这类命令本身均为毫秒级,15s 足够兜底;超时不重试,退避交给上层 outbox。 _CMD_TIMEOUT_SEC = 15.0 # send_text 中“粘贴内容 + 短暂等待 + 回车发送”三步整体的超时(秒)。 # 配合 _open_session_by_name 的 10s,send_text 总上限约 25s,留给 HTTP 往返与中间件 5s。 _SEND_BODY_TIMEOUT_SEC = 15.0 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)。 xdotool / pgrep / kill 均为毫秒级命令,统一用 _CMD_TIMEOUT_SEC 兜底, 防止子进程挂死导致协程永久阻塞。超时不在此重试(退避交给上层 outbox, 与 httpx.NetworkError 只重试一次的策略对齐,避免双重重试放大延迟)。 Args: args: 命令及其参数列表,如 ["xdotool", "search", "--name", "微信"] input_bytes: 需要写入 stdin 的字节 Raises: BridgeError: 命令超时(code=SEND_FAILED,message 指出超时的命令名) """ 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(), ) try: stdout, stderr = await asyncio.wait_for( proc.communicate(input=input_bytes), timeout=_CMD_TIMEOUT_SEC, ) except asyncio.TimeoutError as exc: # 先杀子进程再收尸,避免僵尸进程残留占住管道/资源 proc.kill() await proc.wait() logger.error("[ui] command timeout: %s", args[0]) raise BridgeError( code="SEND_FAILED", message=f"命令超时: {args[0]}", ) from exc return proc.returncode, stdout, stderr async def _key(self, key: str, repeat: int = 1) -> None: """执行 xdotool key [--repeat N] 。 Args: key: 按键名,如 "Escape" / "Return" / "BackSpace" repeat: 重复次数,默认 1 """ logger.info("[ui] key: %s repeat=%d", key, repeat) args = ["xdotool", "key"] if repeat > 1: args.extend(["--repeat", str(repeat)]) args.append(key) rc, _, _ = await self._run(args) logger.info("[ui] key %s -> rc=%s", key, rc) async def _sleep(self, seconds: float) -> None: """asyncio.sleep 封装,便于测试与统一调速。""" logger.info("[ui] sleep %.2fs", seconds) 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: """激活微信窗口并校验焦点是否真正落到该窗口上。 先用非阻塞 windowactivate 避免 --sync 在 VNC 无人操作时死等, 然后轮询 getactivewindow 最多 2 秒确认目标窗口已获得焦点。 校验失败不阻塞流程(部分窗口管理器 getactivewindow 不可靠), 但会打 warning 便于排查。 """ 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)]) # 轮询校验焦点(最多 2 秒,步进 100ms) deadline = time.monotonic() + 2.0 while time.monotonic() < deadline: rc, stdout, _ = await self._run(["xdotool", "getactivewindow"]) if rc == 0: active_text = stdout.decode(errors="ignore").strip() try: if int(active_text) == window_id: return except ValueError: pass await self._sleep(0.1) logger.warning( "[ui] 窗口激活校验失败: window_id=%s 未在 2s 内成为活动窗口", 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 # ------------------------------------------------------------------ # 文本输入(xdotool type) # ------------------------------------------------------------------ async def _paste_via_xclip(self, text: str) -> None: """通过 xdotool type 直接向聚焦控件输入文本。 历史背景:原实现用 xclip 写剪贴板 + Ctrl+V 粘贴,但微信 4.x Linux 自绘 UI(RadiumWMPF 框架,0 子窗口)不响应 X11 core keyboard 的 修饰键组合(Ctrl+V),只响应单字符键盘事件。xev 验证 KeyPress 事件 确实到达窗口但微信不处理,而 xdotool type(逐字符 XSendEvent)能 正常输入中英文。因此改用 xdotool type 直接打字。 方法名保留 _paste_via_xclip 以维持调用点稳定,内部实现已切换。 Args: text: 待输入文本(支持中英文、路径) Raises: BridgeError(SEND_FAILED): xdotool type 执行失败 """ logger.info("[ui] type text: len=%d", len(text)) rc, _, stderr = await self._run(["xdotool", "type", text]) if rc != 0: err = stderr.decode(errors="ignore").strip() if stderr else "unknown" raise BridgeError( code="SEND_FAILED", message=f"xdotool type 失败 (code={rc}): {err}", ) logger.info("[ui] type done rc=%s", rc) # ------------------------------------------------------------------ # 微信 4.x 自绘 UI 布局常量(基于窗口几何的相对坐标) # ------------------------------------------------------------------ # 截图显示:左侧会话列表宽度约 300px,顶部搜索框在左侧栏上方,输入框在右下面板底部。 # 所有坐标通过 getwindowgeometry 获取窗口 (X, Y, WIDTH, HEIGHT) 后动态计算。 _LEFT_PANEL_WIDTH = 300 # 左侧会话列表宽度(像素) _SEARCH_BOX_Y_OFFSET = 55 # 搜索框中心相对窗口顶部的 Y 偏移 _SEARCH_RESULT_Y_OFFSET = 120 # 搜索结果/会话列表第一项中心相对窗口顶部的 Y 偏移 _INPUT_BOX_Y_OFFSET = 50 # 输入框中心相对窗口底部的 Y 偏移(向上) _SEND_BUTTON_X_OFFSET = 60 # 发送按钮中心相对窗口右边缘的 X 偏移(向左) _SEND_BUTTON_Y_OFFSET = 30 # 发送按钮中心相对窗口底部的 Y 偏移(向上) _DEBUG_SCREENSHOTS = True # 是否在关键步骤保存调试截图到 /tmp async def _click_at(self, x: int, y: int, desc: str = "") -> None: """移动鼠标到窗口相对/绝对坐标 (x, y) 并左键单击。""" logger.info("[ui] click at (%d, %d) %s", x, y, desc) rc, _, stderr = await self._run( ["xdotool", "mousemove", "--sync", str(x), str(y)] ) if rc != 0: err = stderr.decode(errors="ignore").strip() if stderr else "unknown" raise BridgeError( code="SEND_FAILED", message=f"移动鼠标到 ({x},{y}) 失败: {err}", ) rc, _, stderr = await self._run(["xdotool", "click", "1"]) if rc != 0: err = stderr.decode(errors="ignore").strip() if stderr else "unknown" raise BridgeError( code="SEND_FAILED", message=f"点击 ({x},{y}) 失败: {err}", ) logger.info("[ui] click done rc=%s", rc) async def _debug_screenshot(self, step: str) -> None: """保存调试截图到 /tmp,文件名包含步骤名和时间戳。""" if not self._DEBUG_SCREENSHOTS: return path = f"/tmp/woc_send_debug_{step}_{int(time.time() * 1000)}.png" rc, _, stderr = await self._run(["scrot", path]) if rc == 0: logger.info("[ui] debug screenshot saved: %s", path) else: err = stderr.decode(errors="ignore").strip() if stderr else "unknown" logger.warning("[ui] debug screenshot failed (%s): %s", step, err) async def _click_input_box(self) -> None: """鼠标点击聊天输入框,强制焦点落到输入框。 微信 4.x 自绘 UI 通过搜索进入会话后,焦点不在聊天输入框, 直接 xdotool type 会导致文字打到错误位置。先获取窗口几何, 点击右下面板底部区域强制聚焦。 """ win_x, win_y, win_w, win_h = await self._get_window_geometry() input_x = win_x + self._LEFT_PANEL_WIDTH + (win_w - self._LEFT_PANEL_WIDTH) // 2 input_y = win_y + win_h - self._INPUT_BOX_Y_OFFSET await self._click_at(input_x, input_y, desc="input box") await self._sleep(0.3) async def _click_send_button(self) -> None: """鼠标点击右下角发送按钮,替代可能失效的 Return 键。""" win_x, win_y, win_w, win_h = await self._get_window_geometry() send_x = win_x + win_w - self._SEND_BUTTON_X_OFFSET send_y = win_y + win_h - self._SEND_BUTTON_Y_OFFSET await self._click_at(send_x, send_y, desc="send button") # ------------------------------------------------------------------ # 会话定位(鼠标点击搜索框 + 点击结果) # ------------------------------------------------------------------ async def _open_session_by_name( self, name: str, timeout_sec: float = 20.0, ) -> None: """通过微信左侧搜索框定位并进入指定联系人的会话。 截图显示顶部左侧有独立搜索框。WeChat 4.x 自绘 UI 对 Ctrl+F 等 X11 修饰键组合不响应,因此全部改用鼠标点击: 1. 激活窗口并校验焦点 2. 按 Esc 关闭可能存在的搜索框/弹窗 3. 点击左侧顶部搜索框 4. 输入 display_name 5. 等待搜索结果渲染 6. 点击第一个搜索结果进入会话 7. 按 Esc 退出搜索覆盖层 Args: name: 用于搜索的显示名(备注/昵称/微信号) timeout_sec: 整体超时(秒),默认 10 秒 Raises: BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口 BridgeError(SEND_FAILED): 超时或 xdotool 操作失败 """ 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() logger.info("[ui] step start: %s", desc) 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: logger.error("[ui] step timeout: %s", desc) raise BridgeError( code="SEND_FAILED", message=f"定位会话步骤超时: {desc}", ) from exc logger.info("[ui] step done: %s", desc) # 1. 查找并激活窗口 window_id = await self.find_wechat_window() logger.info("[ui] found window_id=%s", window_id) if window_id is None: raise BridgeError( code="WINDOW_NOT_FOUND", message="未找到微信窗口,无法定位会话", ) await _step(self._activate_window_fast(window_id), "激活窗口") await _step(self._sleep(1.0), "等待窗口激活") # 2. 关闭可能存在的搜索框/弹窗 await _step(self._key("Escape"), "关闭搜索框/弹窗") await _step(self._sleep(1.0), "等待 Esc 生效") # 3. 获取窗口几何,计算搜索框/结果坐标 win_x, win_y, win_w, win_h = await self._get_window_geometry() search_x = win_x + self._LEFT_PANEL_WIDTH // 2 search_y = win_y + self._SEARCH_BOX_Y_OFFSET result_x = win_x + self._LEFT_PANEL_WIDTH // 2 result_y = win_y + self._SEARCH_RESULT_Y_OFFSET # 4. 点击搜索框并输入关键词 await _step(self._click_at(search_x, search_y, desc="search box"), "点击搜索框") await _step(self._sleep(1.0), "等待搜索框聚焦") # 清空可能存在的旧内容:用多次 Backspace 规避 Ctrl+A 可能失效的问题 await _step(self._key("BackSpace", repeat=30), "清空搜索框旧内容") await _step(self._sleep(1.0), "等待清空") await _step(self._paste_via_xclip(name), "输入搜索关键词") await _step(self._sleep(1.0), "等待搜索结果") # 5. 点击第一个搜索结果进入会话 await _step(self._click_at(result_x, result_y, desc="first search result"), "点击第一个搜索结果") await _step(self._sleep(1.0), "等待会话打开") await self._debug_screenshot("after_open_session") # 6. 退出搜索模式,确保焦点在会话区域 await _step(self._key("Escape"), "退出搜索模式") await _step(self._sleep(1.0), "等待焦点稳定") # ------------------------------------------------------------------ # 发送文本 # ------------------------------------------------------------------ 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__<随机> """ name = display_name if display_name else to_wxid t_total = time.perf_counter() logger.info("[send_text] 开始 to=%s name=%s content_len=%d", to_wxid, name, len(content)) # 1. 定位会话(_open_session_by_name 内部已含 10s 整体超时) t0 = time.perf_counter() await self._open_session_by_name(name) logger.info("[send_text] 会话定位完成 (%.0fms)", (time.perf_counter() - t0) * 1000) await self._debug_screenshot("after_session") # 1.5 鼠标点击聊天输入框强制焦点 # 微信 4.x 自绘 UI 进入会话后焦点不在输入框,xdotool type 会打到错误位置。 await self._click_input_box() await self._debug_screenshot("after_click_input") # 2. 输入内容并发送(三步整体超时保护,与定位会话的 10s 合计最坏 25s) async def _send_body() -> None: await self._paste_via_xclip(content) logger.info("[send_text] 文本已输入") await self._debug_screenshot("after_type") await self._sleep(1.0) # 优先用鼠标点击右下角发送按钮,规避 Return 键可能不触发发送的问题 await self._click_send_button() try: t0 = time.perf_counter() await asyncio.wait_for(_send_body(), timeout=_SEND_BODY_TIMEOUT_SEC) logger.info("[send_text] 发送按钮已点击 (%.0fms)", (time.perf_counter() - t0) * 1000) except asyncio.TimeoutError as exc: logger.error("[send_text] 输入/发送步骤超时") raise BridgeError( code="SEND_FAILED", message="文本输入/发送步骤超时", ) from exc await self._sleep(1.0) await self._debug_screenshot("after_send") # 3. 按 Esc 退出当前聊天界面,回到会话列表,避免影响下一次发送 await self._key("Escape") await self._sleep(1.0) await self._debug_screenshot("after_escape") # 4. 生成 local_send_id(本地 ID,非微信原生 msg_id) local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}" logger.info("[send_text] 完成 local_send_id=%s (总耗时 %.0fms)", local_send_id, (time.perf_counter() - t_total) * 1000) 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) # 1.5 鼠标点击聊天输入框强制焦点(与 send_text 同理) await self._click_input_box() # 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) # ------------------------------------------------------------------ # 发表朋友圈(纯文字) # ------------------------------------------------------------------ async def publish_moment( self, content: str, timeout_sec: float = 20.0, ) -> str: """发表纯文字朋友圈。 流程: 1. 激活微信窗口 2. 获取窗口几何,点击左侧栏「朋友圈」图标进入朋友圈页 3. 点击右上角相机图标 → 选择「发表文字」 4. 粘贴文字内容 5. 点击「发表」按钮 6. 按 Esc 关闭朋友圈页回到主界面 注意:下方点击坐标均为估算值(基于微信 4.0 Linux 默认布局), 需在目标分辨率实测后调优: - 朋友圈入口:左侧栏底部图标,约 x=win_x+30, y=win_y+win_h-90 - 相机图标:朋友圈页右上角,约 x=win_x+win_w-30, y=win_y+30 - 「发表文字」菜单项:相机下方第 1 项 - 「发表」按钮:发表文字弹窗右上角,约 x=win_x+win_w-60, y=win_y+60 Args: content: 朋友圈文字内容 timeout_sec: 整体超时(秒),默认 20 Returns: 本地生成的 local_moment_id,格式 moment__<随机> Raises: BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口 BridgeError(SEND_FAILED): 超时或 UI 操作失败 """ if not content: raise BridgeError( code="SEND_FAILED", message="content 不能为空,无法发表朋友圈", ) 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. 获取窗口几何 returncode, stdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(window_id)] ) if returncode != 0: raise BridgeError( code="SEND_FAILED", message="无法获取微信窗口几何信息", ) 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="SEND_FAILED", message="解析窗口几何信息失败", ) # 3. 关闭可能存在的弹窗/搜索框 await _step(self._key("Escape"), "关闭弹窗") await _step(self._sleep(0.2), "等待 Esc 生效") # 4. 点击左侧栏「朋友圈」图标(估算:左栏底部偏上) moment_x = win_x + 30 moment_y = win_y + win_h - 90 await _step(self._click(moment_x, moment_y), "点击朋友圈入口") await _step(self._sleep(1.0), "等待朋友圈页打开") # 5. 点击右上角相机图标(估算:窗口右上角) camera_x = win_x + win_w - 30 camera_y = win_y + 30 await _step(self._click(camera_x, camera_y), "点击相机图标") await _step(self._sleep(0.6), "等待发表菜单弹出") # 6. 选择「发表文字」(菜单第 1 项,按 Down 选中 + 回车) await _step(self._key("Down"), "选中发表文字") await _step(self._sleep(0.2), "等待选中") await _step(self._key("Return"), "进入发表文字") await _step(self._sleep(0.8), "等待发表文字弹窗") # 7. 粘贴文字内容 await _step(self._paste_via_xclip(content), "粘贴朋友圈内容") await _step(self._sleep(0.3), "等待粘贴完成") # 8. 点击「发表」按钮(估算:弹窗右上角) publish_x = win_x + win_w - 60 publish_y = win_y + 60 await _step(self._click(publish_x, publish_y), "点击发表按钮") await _step(self._sleep(1.0), "等待发表完成") # 9. 点击左上角返回按钮回到聊天主界面(估算:窗口左上角) back_x = win_x + 30 back_y = win_y + 30 await _step(self._click(back_x, back_y), "点击返回按钮") await _step(self._sleep(0.5), "等待返回主界面") # 兜底 Esc,关闭可能残留的弹窗(如发表成功提示) await _step(self._key("Escape"), "兜底关闭弹窗") await _step(self._sleep(0.2), "等待焦点稳定") # 10. 生成 local_moment_id local_moment_id = f"moment_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}" return local_moment_id async def _click(self, x: int, y: int) -> None: """移动鼠标到 (x, y) 并左键单击。""" rc, _, _ = await self._run( ["xdotool", "mousemove", "--sync", str(x), str(y)] ) if rc != 0: raise BridgeError( code="SEND_FAILED", message=f"移动鼠标到 ({x},{y}) 失败", ) rc, _, _ = await self._run(["xdotool", "click", "1"]) if rc != 0: raise BridgeError( code="SEND_FAILED", message=f"点击 ({x},{y}) 失败", ) async def _long_press(self, x: int, y: int, hold_sec: float = 1.5) -> None: """移动鼠标到 (x, y) 并长按左键 hold_sec 秒后释放。 用于触发微信消息长按菜单(撤回/转发/复制等)。 微信长按阈值约 1~2 秒,默认 1.5 秒。 用 try/finally 保证 mouseup 一定执行,避免超时/取消时 mousedown 状态泄漏导致整个 UI 自动化瘫痪(拖拽锁死)。 """ rc, _, _ = await self._run( ["xdotool", "mousemove", "--sync", str(x), str(y)] ) if rc != 0: raise BridgeError( code="SEND_FAILED", message=f"长按:移动鼠标到 ({x},{y}) 失败", ) rc, _, _ = await self._run(["xdotool", "mousedown", "1"]) if rc != 0: raise BridgeError( code="SEND_FAILED", message=f"长按 ({x},{y}) mousedown 失败", ) try: await self._sleep(hold_sec) finally: # 用 shield 防止 finally 中的 mouseup 被再次取消 rc, _, _ = await asyncio.shield( self._run(["xdotool", "mouseup", "1"]) ) if rc != 0: logger.warning( "_long_press mouseup 失败 (rc=%d),鼠标可能仍处于按下状态", rc ) async def _right_click(self, x: int, y: int) -> None: """移动鼠标到 (x, y) 并右键单击(button 3)。""" rc, _, _ = await self._run( ["xdotool", "mousemove", "--sync", str(x), str(y)] ) if rc != 0: raise BridgeError( code="SEND_FAILED", message=f"右键:移动鼠标到 ({x},{y}) 失败", ) rc, _, _ = await self._run(["xdotool", "click", "3"]) if rc != 0: raise BridgeError( code="SEND_FAILED", message=f"右键点击 ({x},{y}) 失败", ) async def _get_window_geometry(self) -> tuple[int, int, int, int]: """获取微信窗口几何信息,返回 (win_x, win_y, win_w, win_h)。 统一封装 getwindowgeometry --shell 解析逻辑,消除 8+ 处重复代码。 """ window_id = await self.find_wechat_window() if window_id is None: raise BridgeError( code="WINDOW_NOT_FOUND", message="未找到微信窗口", ) returncode, stdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(window_id)] ) if returncode != 0: raise BridgeError( code="SEND_FAILED", message="无法获取微信窗口几何信息", ) geom = {} for line in stdout.decode(errors="ignore").splitlines(): if "=" in line: k, v = line.split("=", 1) geom[k.strip()] = v.strip() try: return ( int(geom.get("X", 0)), int(geom.get("Y", 0)), int(geom.get("WIDTH", 800)), int(geom.get("HEIGHT", 600)), ) except ValueError: raise BridgeError( code="SEND_FAILED", message="解析窗口几何信息失败", ) async def _enter_moments_page(self) -> tuple[int, int, int, int]: """进入朋友圈页面并返回窗口几何 (win_x, win_y, win_w, win_h)。 统一朋友圈互动接口(like/comment/delete)的前置步骤: 激活窗口 → 获取几何 → 关闭弹窗 → 点击朋友圈入口 → 等待打开。 避免在聊天主界面执行朋友圈操作导致误删聊天消息(C3 风险)。 """ window_id = await self.find_wechat_window() if window_id is None: raise BridgeError( code="WINDOW_NOT_FOUND", message="未找到微信窗口,无法进入朋友圈", ) await self._activate_window_fast(window_id) await self._sleep(0.3) win_x, win_y, win_w, win_h = await self._get_window_geometry() # 关闭可能存在的弹窗/搜索框 await self._key("Escape") await self._sleep(0.2) # 点击左侧栏「朋友圈」图标(估算:左栏底部偏上) moment_x = win_x + 30 moment_y = win_y + win_h - 90 await self._click(moment_x, moment_y) await self._sleep(1.0) return (win_x, win_y, win_w, win_h) # ------------------------------------------------------------------ # 撤回消息(experimental) # ------------------------------------------------------------------ async def revoke_message( self, talker: str, create_time: int, display_name: Optional[str] = None, timeout_sec: float = 15.0, ) -> bool: """撤回指定会话中最近发送的消息(experimental)。 微信撤回限制:发送后 2 分钟内可撤回。本方法通过以下 UI 路径实现: 1. 定位到指定会话(复用 _open_session_by_name) 2. 获取窗口几何,估算最近一条自己发送的消息气泡位置 (聊天区域底部偏上,约 y=win_y+win_h-150) 3. 长按该位置触发消息菜单 4. 按 Esc 关闭可能的多级菜单,再重新长按 5. 在弹出的菜单中查找"撤回"项(估算菜单项位置) Args: talker: 会话对方 wxid(用于 DB 解析 display_name) create_time: 消息发送时间戳,必须 > 0,用于校验是否在 2 分钟撤回窗口内 display_name: 可选,会话显示名(备注/昵称)用于搜索框定位; 不传则用 talker (wxid) 作为搜索关键词 timeout_sec: 总超时秒数 Returns: True 表示 UI 操作流程执行完成(不代表微信一定撤回成功) Raises: BridgeError(INVALID_PARAMS): create_time <= 0 BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口 BridgeError(SEND_FAILED): 超时或 UI 操作失败 BridgeError(REVOKE_WINDOW_EXPIRED): 超过 2 分钟撤回时限 Notes: - experimental:消息气泡坐标为估算值,需在目标分辨率实测调优 - 撤回菜单项位置同样为估算,不同微信版本菜单顺序可能不同 - 无法校验撤回是否真正生效(无 UI 元素检测) """ # create_time 必须有效,避免绕过时限校验 if create_time <= 0: raise BridgeError( code="INVALID_PARAMS", message=f"create_time 必须 > 0,收到 {create_time}", ) # 撤回时间窗校验(2 分钟 = 120 秒) now = int(time.time()) if now - create_time > 120: raise BridgeError( code="REVOKE_WINDOW_EXPIRED", message=f"消息发送于 {now - create_time}s 前,超过 120s 撤回时限", ) deadline = time.monotonic() + timeout_sec async def _step(coro: Awaitable[None], desc: str) -> None: remaining = deadline - time.monotonic() if remaining <= 0: if asyncio.iscoroutine(coro): coro.close() 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. 定位会话(display_name 优先,回退到 wxid) search_name = display_name or talker await _step(self._open_session_by_name(search_name), "定位会话") # 2. 获取窗口几何 window_id = await self.find_wechat_window() if window_id is None: raise BridgeError( code="WINDOW_NOT_FOUND", message="未找到微信窗口,无法撤回消息", ) returncode, stdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(window_id)] ) if returncode != 0: raise BridgeError( code="SEND_FAILED", message="无法获取微信窗口几何信息", ) 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="SEND_FAILED", message="解析窗口几何信息失败", ) # 3. 估算最近一条消息气泡位置并长按 # 聊天区域底部偏上(最新消息通常在输入框上方约 150px) # 右半区域(自己发送的消息在右侧) msg_x = win_x + win_w * 3 // 4 msg_y = win_y + win_h - 150 await _step(self._long_press(msg_x, msg_y, 1.5), "长按消息") await _step(self._sleep(0.6), "等待菜单弹出") # 4. 在菜单中查找"撤回"项 # 估算:菜单出现在长按位置附近,撤回通常在菜单中部 # 按方向键导航 + 回车选择 await _step(self._key("Down"), "导航到撤回项") await _step(self._sleep(0.2), "等待选中") await _step(self._key("Down"), "导航到撤回项") await _step(self._sleep(0.2), "等待选中") await _step(self._key("Return"), "选择撤回") # 5. 可能有确认对话框 await _step(self._sleep(0.5), "等待确认对话框") await _step(self._key("Return"), "确认撤回") await _step(self._sleep(0.5), "等待撤回完成") logger.info( "revoke_message: talker=%s create_time=%d → UI 操作完成", talker, create_time, ) return True # ------------------------------------------------------------------ # 转发消息(experimental) # ------------------------------------------------------------------ async def forward_message( self, talker: str, target_display_name: str, source_display_name: Optional[str] = None, timeout_sec: float = 20.0, ) -> bool: """转发指定会话中的消息到另一会话(experimental)。 UI 路径: 1. 定位到源会话 2. 长按最近一条消息触发菜单 3. 选择"转发" 4. 在转发目标选择界面搜索目标联系人 5. 选中目标并确认转发 Args: talker: 源会话对方 wxid(用于 DB 解析 source_display_name) target_display_name: 转发目标联系人的显示名 source_display_name: 可选,源会话显示名用于搜索框定位; 不传则用 talker (wxid) 作为搜索关键词 timeout_sec: 总超时秒数 Returns: True 表示 UI 操作流程执行完成 Raises: BridgeError(WINDOW_NOT_FOUND / SEND_FAILED) Notes: - experimental:消息气泡与菜单项坐标均为估算 - 转发目标选择界面是独立窗口,坐标可能与主窗口不同 """ deadline = time.monotonic() + timeout_sec async def _step(coro: Awaitable[None], desc: str) -> None: remaining = deadline - time.monotonic() if remaining <= 0: if asyncio.iscoroutine(coro): coro.close() 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. 定位源会话(source_display_name 优先,回退到 wxid) search_name = source_display_name or talker await _step(self._open_session_by_name(search_name), "定位源会话") # 2. 获取窗口几何 window_id = await self.find_wechat_window() if window_id is None: raise BridgeError( code="WINDOW_NOT_FOUND", message="未找到微信窗口,无法转发消息", ) returncode, stdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(window_id)] ) if returncode != 0: raise BridgeError( code="SEND_FAILED", message="无法获取微信窗口几何信息", ) 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="SEND_FAILED", message="解析窗口几何信息失败", ) # 3. 长按最近消息 msg_x = win_x + win_w // 2 msg_y = win_y + win_h - 150 await _step(self._long_press(msg_x, msg_y, 1.5), "长按消息") await _step(self._sleep(0.6), "等待菜单弹出") # 4. 选择"转发"(菜单项通常在"复制"附近) await _step(self._key("Down"), "导航到转发项") await _step(self._sleep(0.2), "等待选中") await _step(self._key("Return"), "选择转发") await _step(self._sleep(1.0), "等待转发界面打开") # 5. 在转发界面搜索目标 # 转发界面通常有搜索框,复用 Ctrl+F + 粘贴逻辑 await _step(self._key("ctrl+f"), "打开转发搜索") await _step(self._sleep(0.4), "等待搜索框") await _step(self._paste_via_xclip(target_display_name), "粘贴目标名称") await _step(self._sleep(0.8), "等待搜索结果") # 6. 选中第一个结果 await _step(self._key("Down"), "选中目标") await _step(self._sleep(0.3), "等待选中") await _step(self._key("Return"), "进入选择") # 7. 确认转发 await _step(self._sleep(0.5), "等待确认按钮") await _step(self._key("Return"), "确认转发") await _step(self._sleep(1.0), "等待转发完成") logger.info( "forward_message: talker=%s target=%s → UI 操作完成", talker, target_display_name, ) return True # ------------------------------------------------------------------ # 修改好友备注(experimental) # ------------------------------------------------------------------ async def set_contact_remark( self, talker: str, remark: str, display_name: Optional[str] = None, timeout_sec: float = 15.0, ) -> bool: """修改好友备注名(experimental)。 UI 路径: 1. 定位到指定会话 2. 点击右上角"..."菜单 3. 点击"备注"项 4. 清空输入框,粘贴新备注 5. 点击"完成"按钮 Args: talker: 好友 wxid(用于 DB 解析 display_name) remark: 新备注名 display_name: 可选,会话显示名用于搜索框定位; 不传则用 talker (wxid) 作为搜索关键词 timeout_sec: 总超时秒数 Returns: True 表示 UI 操作流程执行完成 Raises: BridgeError(WINDOW_NOT_FOUND / SEND_FAILED) Notes: - experimental:菜单项与按钮坐标均为估算 """ if not remark: raise BridgeError( code="INVALID_PARAMS", message="remark 不能为空", ) deadline = time.monotonic() + timeout_sec async def _step(coro: Awaitable[None], desc: str) -> None: remaining = deadline - time.monotonic() if remaining <= 0: if asyncio.iscoroutine(coro): coro.close() 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. 定位会话(display_name 优先,回退到 wxid) search_name = display_name or talker await _step(self._open_session_by_name(search_name), "定位会话") # 2. 获取窗口几何 window_id = await self.find_wechat_window() if window_id is None: raise BridgeError( code="WINDOW_NOT_FOUND", message="未找到微信窗口,无法修改备注", ) returncode, stdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(window_id)] ) if returncode != 0: raise BridgeError( code="SEND_FAILED", message="无法获取微信窗口几何信息", ) 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="SEND_FAILED", message="解析窗口几何信息失败", ) # 3. 点击右上角"..."菜单 menu_x = win_x + win_w - 30 menu_y = win_y + 30 await _step(self._click(menu_x, menu_y), "点击更多菜单") await _step(self._sleep(0.6), "等待菜单弹出") # 4. 选择"备注"项(通常在菜单第一项) await _step(self._key("Down"), "导航到备注项") await _step(self._sleep(0.2), "等待选中") await _step(self._key("Return"), "进入备注设置") await _step(self._sleep(0.8), "等待备注输入框") # 5. 清空并粘贴新备注 await _step(self._key("ctrl+a"), "全选旧备注") await _step(self._sleep(0.1), "等待全选") await _step(self._paste_via_xclip(remark), "粘贴新备注") await _step(self._sleep(0.3), "等待粘贴完成") # 6. 点击"完成"按钮(估算:输入框右侧或弹窗右上角) done_x = win_x + win_w - 60 done_y = win_y + 60 await _step(self._click(done_x, done_y), "点击完成按钮") await _step(self._sleep(0.5), "等待保存完成") logger.info( "set_contact_remark: talker=%s remark=%s → UI 操作完成", talker, remark, ) return True # ------------------------------------------------------------------ # 添加好友(experimental) # ------------------------------------------------------------------ async def add_friend( self, keyword: str, message: str = "", timeout_sec: float = 20.0, ) -> bool: """搜索并添加好友(experimental)。 UI 路径: 1. 激活微信窗口 2. Ctrl+F 搜索关键词(wxid/手机号/微信号) 3. 在搜索结果中点击"添加联系人" 4. 可选:填写验证消息 5. 点击"发送"提交好友请求 Args: keyword: 搜索关键词(wxid/手机号/微信号) message: 可选好友验证消息,空则用默认 Returns: True 表示 UI 操作流程执行完成 Raises: BridgeError(WINDOW_NOT_FOUND / SEND_FAILED) Notes: - experimental:搜索结果与按钮坐标均为估算 - 无法校验对方是否已收到请求 """ if not keyword: raise BridgeError( code="INVALID_PARAMS", message="keyword 不能为空", ) deadline = time.monotonic() + timeout_sec async def _step(coro: Awaitable[None], desc: str) -> None: remaining = deadline - time.monotonic() if remaining <= 0: if asyncio.iscoroutine(coro): coro.close() 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. 激活窗口 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), "等待搜索框打开") await _step(self._key("ctrl+a"), "全选搜索框内容") await _step(self._sleep(0.1), "等待全选") await _step(self._paste_via_xclip(keyword), "粘贴搜索关键词") await _step(self._sleep(1.0), "等待搜索结果") # 4. 获取窗口几何,定位"添加联系人"按钮 returncode, stdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(window_id)] ) if returncode != 0: raise BridgeError( code="SEND_FAILED", message="无法获取微信窗口几何信息", ) 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="SEND_FAILED", message="解析窗口几何信息失败", ) # 5. 点击搜索结果中的"添加联系人"按钮 # 估算:搜索结果区域右侧 add_x = win_x + win_w - 80 add_y = win_y + 120 await _step(self._click(add_x, add_y), "点击添加联系人") await _step(self._sleep(0.8), "等待添加界面打开") # 6. 可选:填写验证消息 if message and message.strip(): await _step(self._key("ctrl+a"), "全选验证消息框") await _step(self._sleep(0.1), "等待全选") await _step(self._paste_via_xclip(message), "粘贴验证消息") await _step(self._sleep(0.3), "等待粘贴完成") # 7. 点击"发送"按钮提交好友请求 send_x = win_x + win_w - 60 send_y = win_y + win_h - 60 await _step(self._click(send_x, send_y), "点击发送按钮") await _step(self._sleep(0.8), "等待请求发送") # 8. 返回主界面 await _step(self._key("Escape"), "返回主界面") await _step(self._sleep(0.3), "等待返回") logger.info( "add_friend: keyword=%s message_len=%d → UI 操作完成", keyword, len(message) if message else 0, ) return True # ------------------------------------------------------------------ # 朋友圈互动(experimental) # ------------------------------------------------------------------ async def moment_like( self, moment_index: int = 1, timeout_sec: float = 20.0, ) -> bool: """给朋友圈点赞(experimental)。 自动进入朋友圈页面后再执行点赞操作,避免在聊天主界面误触。 Args: moment_index: 朋友圈在时间线中的序号,1=最新,2=次新... Returns: True 表示 UI 操作流程执行完成 """ if moment_index < 1: raise BridgeError( code="INVALID_PARAMS", message=f"moment_index 必须 >= 1,收到 {moment_index}", ) deadline = time.monotonic() + timeout_sec async def _step(coro: Awaitable, desc: str): """执行协程,带 deadline 检查。返回协程结果。""" remaining = deadline - time.monotonic() if remaining <= 0: if asyncio.iscoroutine(coro): coro.close() raise BridgeError( code="SEND_FAILED", message=f"朋友圈点赞超时: {desc}", ) try: return 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 # 进入朋友圈页面(自动激活窗口 + 导航) win_x, win_y, win_w, win_h = await _step( self._enter_moments_page(), "进入朋友圈页面" ) # 估算第 N 条朋友圈的"赞/评论"按钮位置 # 朋友圈每条约 200px 高,按钮在右下角 like_x = win_x + win_w - 30 like_y = win_y + 90 + (moment_index - 1) * 200 await _step(self._click(like_x, like_y), "点击赞/评论按钮") await _step(self._sleep(0.5), "等待弹窗") # 弹窗中"赞"通常是第一项 await _step(self._key("Down"), "选中赞") await _step(self._sleep(0.2), "等待选中") await _step(self._key("Return"), "点击赞") await _step(self._sleep(0.5), "等待点赞完成") logger.info("moment_like: index=%d → UI 操作完成", moment_index) return True async def moment_comment( self, comment: str, moment_index: int = 1, timeout_sec: float = 20.0, ) -> bool: """评论朋友圈(experimental)。 自动进入朋友圈页面后再执行评论操作。 Args: comment: 评论文字内容 moment_index: 朋友圈序号,1=最新 """ if not comment: raise BridgeError( code="INVALID_PARAMS", message="comment 不能为空", ) if moment_index < 1: raise BridgeError( code="INVALID_PARAMS", message=f"moment_index 必须 >= 1,收到 {moment_index}", ) deadline = time.monotonic() + timeout_sec async def _step(coro: Awaitable, desc: str): """执行协程,带 deadline 检查。返回协程结果。""" remaining = deadline - time.monotonic() if remaining <= 0: if asyncio.iscoroutine(coro): coro.close() raise BridgeError( code="SEND_FAILED", message=f"朋友圈评论超时: {desc}", ) try: return 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 # 进入朋友圈页面 win_x, win_y, win_w, win_h = await _step( self._enter_moments_page(), "进入朋友圈页面" ) # 点击"赞/评论"按钮 like_x = win_x + win_w - 30 like_y = win_y + 90 + (moment_index - 1) * 200 await _step(self._click(like_x, like_y), "点击赞/评论按钮") await _step(self._sleep(0.5), "等待弹窗") # "评论"通常是第二项 await _step(self._key("Down"), "选中评论") await _step(self._sleep(0.2), "等待选中") await _step(self._key("Down"), "选中评论") await _step(self._sleep(0.2), "等待选中") await _step(self._key("Return"), "点击评论") await _step(self._sleep(0.5), "等待输入框") # 粘贴评论内容 await _step(self._paste_via_xclip(comment), "粘贴评论内容") await _step(self._sleep(0.3), "等待粘贴完成") # 发送 await _step(self._key("Return"), "发送评论") await _step(self._sleep(0.5), "等待发送完成") logger.info( "moment_comment: index=%d comment_len=%d → UI 操作完成", moment_index, len(comment), ) return True async def moment_delete( self, moment_index: int = 1, timeout_sec: float = 20.0, ) -> bool: """删除自己的朋友圈(experimental)。 自动进入朋友圈页面后再执行删除操作,避免在聊天主界面 长按消息导致误删聊天消息(不可逆数据丢失)。 Args: moment_index: 朋友圈序号,1=最新 """ if moment_index < 1: raise BridgeError( code="INVALID_PARAMS", message=f"moment_index 必须 >= 1,收到 {moment_index}", ) deadline = time.monotonic() + timeout_sec async def _step(coro: Awaitable, desc: str): """执行协程,带 deadline 检查。返回协程结果。""" remaining = deadline - time.monotonic() if remaining <= 0: if asyncio.iscoroutine(coro): coro.close() raise BridgeError( code="SEND_FAILED", message=f"删除朋友圈超时: {desc}", ) try: return 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 # 进入朋友圈页面(关键安全步骤:避免在聊天界面误删消息) win_x, win_y, win_w, win_h = await _step( self._enter_moments_page(), "进入朋友圈页面" ) # 长按自己的朋友圈触发删除菜单 moment_x = win_x + win_w // 2 moment_y = win_y + 90 + (moment_index - 1) * 200 await _step(self._long_press(moment_x, moment_y, 1.5), "长按朋友圈") await _step(self._sleep(0.6), "等待菜单弹出") # 选择"删除" await _step(self._key("Down"), "导航到删除") await _step(self._sleep(0.2), "等待选中") await _step(self._key("Return"), "选择删除") await _step(self._sleep(0.5), "等待确认对话框") # 确认删除 await _step(self._key("Return"), "确认删除") await _step(self._sleep(0.5), "等待删除完成") logger.info("moment_delete: index=%d → UI 操作完成", moment_index) return True async def publish_moment_with_image( self, image_path: str, content: str = "", timeout_sec: float = 25.0, ) -> str: """发表带图片的朋友圈(experimental)。 Args: image_path: 图片文件容器内绝对路径 content: 可选文字内容 """ if not image_path or not os.path.isfile(image_path): raise BridgeError( code="INVALID_PARAMS", message=f"图片不存在: {image_path}", ) if not os.access(image_path, os.R_OK): raise BridgeError( code="INVALID_PARAMS", message=f"图片不可读: {image_path}", ) deadline = time.monotonic() + timeout_sec async def _step(coro: Awaitable[None], desc: str) -> None: remaining = deadline - time.monotonic() if remaining <= 0: if asyncio.iscoroutine(coro): coro.close() 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 # 复用纯文字朋友圈的前置步骤:激活窗口 → 进入朋友圈 → 点相机 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), "等待窗口激活") returncode, stdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(window_id)] ) if returncode != 0: raise BridgeError( code="SEND_FAILED", message="无法获取微信窗口几何信息", ) 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="SEND_FAILED", message="解析窗口几何信息失败", ) # 进入朋友圈 await _step(self._key("Escape"), "关闭弹窗") await _step(self._sleep(0.2), "等待 Esc 生效") moment_x = win_x + 30 moment_y = win_y + win_h - 90 await _step(self._click(moment_x, moment_y), "点击朋友圈入口") await _step(self._sleep(1.0), "等待朋友圈页打开") # 点相机 → 选"发表图片"(第 2 项,Down 2 次) camera_x = win_x + win_w - 30 camera_y = win_y + 30 await _step(self._click(camera_x, camera_y), "点击相机图标") await _step(self._sleep(0.6), "等待发表菜单弹出") await _step(self._key("Down"), "选中发表图片") await _step(self._sleep(0.2), "等待选中") await _step(self._key("Down"), "选中发表图片") await _step(self._sleep(0.2), "等待选中") await _step(self._key("Return"), "进入发表图片") await _step(self._sleep(0.8), "等待文件选择器") # 文件选择器:粘贴图片路径并回车 await _step(self._paste_via_xclip(image_path), "粘贴图片路径") await _step(self._sleep(0.3), "等待粘贴完成") await _step(self._key("Return"), "确认选择图片") await _step(self._sleep(1.0), "等待图片加载") # 可选:输入文字 if content and content.strip(): await _step(self._paste_via_xclip(content), "粘贴文字内容") await _step(self._sleep(0.3), "等待粘贴完成") # 点击"发表"按钮 publish_x = win_x + win_w - 60 publish_y = win_y + 60 await _step(self._click(publish_x, publish_y), "点击发表按钮") await _step(self._sleep(1.0), "等待发表完成") # 返回主界面 back_x = win_x + 30 back_y = win_y + 30 await _step(self._click(back_x, back_y), "点击返回按钮") await _step(self._sleep(0.5), "等待返回主界面") await _step(self._key("Escape"), "兜底关闭弹窗") await _step(self._sleep(0.2), "等待焦点稳定") local_moment_id = f"moment_img_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}" logger.info( "publish_moment_with_image: image=%s content_len=%d → 成功 local_moment_id=%s", image_path, len(content) if content else 0, local_moment_id, ) return local_moment_id # ------------------------------------------------------------------ # 分享公众号文章到朋友圈 # ------------------------------------------------------------------ async def share_article_moment( self, public_account: str, article_index: int = 1, comment: Optional[str] = None, timeout_sec: float = 40.0, ) -> str: """分享公众号文章到朋友圈。 流程: 1. 通过搜索定位公众号会话(复用 _open_session_by_name) 2. 在会话中点击第 article_index 篇推送文章卡片 3. 轮询等待文章页加载完成(最长 10s) 4. 点击文章页右上角「⋯」菜单 5. 点击「分享到朋友圈」 6. 可选:在「说点什么」输入框粘贴 comment 7. 点击「发表」 8. 等待分享完成 → 关闭文章页 → 回到聊天主界面 注意:下方点击坐标均为估算值(基于微信 4.0 Linux 默认布局), 需在目标分辨率实测后调优: - 文章卡片:会话顶部第 N 条消息位置,估算 y=win_y+90+(N-1)*80 - 「⋯」菜单按钮:文章页右上角,约 x=win_x+win_w-30, y=win_y+30 - 「分享到朋友圈」菜单项:菜单第 1 项(Down 选中 + 回车) - 「说点什么」输入框:分享弹窗顶部,约 y=win_y+90 - 「发表」按钮:分享弹窗右上角,约 x=win_x+win_w-30, y=win_y+30 - 返回按钮:文章页左上角,约 x=win_x+30, y=win_y+30 Args: public_account: 公众号名称或 wxid,用于搜索定位 article_index: 从会话顶部往下的第几篇推送文章卡片,默认 1 comment: 可选「说点什么」评论文字,None/空串跳过 timeout_sec: 整体超时(秒),默认 40(含文章页加载等待) Returns: 本地生成的 local_moment_id,格式 share__<随机> Raises: BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口 BridgeError(SEND_FAILED): 超时或 UI 操作失败 """ if not public_account: raise BridgeError( code="SEND_FAILED", message="public_account 不能为空,无法定位公众号会话", ) if article_index < 1: raise BridgeError( code="SEND_FAILED", message=f"article_index 必须 >= 1,收到 {article_index}", ) 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. 定位公众号会话(复用 _open_session_by_name) await _step( self._open_session_by_name(public_account), "定位公众号会话", ) # 2. 获取窗口几何(用于估算文章卡片坐标) window_id = await self.find_wechat_window() if window_id is None: raise BridgeError( code="WINDOW_NOT_FOUND", message="未找到微信窗口,无法获取几何信息", ) returncode, stdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(window_id)] ) if returncode != 0: raise BridgeError( code="SEND_FAILED", message="无法获取微信窗口几何信息", ) 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)) except ValueError: raise BridgeError( code="SEND_FAILED", message="解析窗口几何信息失败", ) # 3. 点击第 article_index 篇推送文章卡片 # 估算:会话顶部偏下,每条消息约 80px 高,卡片居中点击 card_x = win_x + win_w // 2 card_y = win_y + 90 + (article_index - 1) * 80 await _step(self._click(card_x, card_y), f"点击第 {article_index} 篇文章卡片") # 4. 等待文章页加载(最长 10s,分步等待避免单次长睡) for _ in range(20): remaining = deadline - time.monotonic() if remaining <= 0: raise BridgeError( code="SEND_FAILED", message="分享文章超时: 等待文章页加载", ) await _step(self._sleep(0.5), "等待文章页加载") # 加载完成后窗口可能已切换到文章页,重新获取几何 returncode, stdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(window_id)] ) if returncode == 0: geom2 = {} for line in stdout.decode(errors="ignore").splitlines(): if "=" in line: k, v = line.split("=", 1) geom2[k.strip()] = v.strip() try: win_x = int(geom2.get("X", win_x)) win_y = int(geom2.get("Y", win_y)) win_w = int(geom2.get("WIDTH", win_w)) except ValueError: pass # 解析失败用旧值 # 5. 点击文章页右上角「⋯」菜单按钮 menu_x = win_x + win_w - 30 menu_y = win_y + 30 await _step(self._click(menu_x, menu_y), "点击更多菜单") await _step(self._sleep(0.6), "等待菜单弹出") # 6. 选择「分享到朋友圈」(菜单第 1 项,Down 选中 + 回车) await _step(self._key("Down"), "选中分享到朋友圈") await _step(self._sleep(0.2), "等待选中") await _step(self._key("Return"), "进入分享到朋友圈") await _step(self._sleep(1.0), "等待分享弹窗打开") # 7. 可选:粘贴「说点什么」评论文字 if comment and comment.strip(): # 点击「说点什么」输入框定位焦点(估算:分享弹窗顶部偏下) comment_x = win_x + win_w // 2 comment_y = win_y + 90 await _step(self._click(comment_x, comment_y), "点击说点什么输入框") await _step(self._sleep(0.3), "等待输入框聚焦") await _step(self._paste_via_xclip(comment), "粘贴评论文字") await _step(self._sleep(0.3), "等待粘贴完成") # 8. 点击「发表」按钮(估算:分享弹窗右上角) publish_x = win_x + win_w - 30 publish_y = win_y + 30 await _step(self._click(publish_x, publish_y), "点击发表按钮") await _step(self._sleep(1.5), "等待分享完成") # 9. 关闭文章页回到聊天主界面 # 点击左上角返回按钮 + 兜底 Esc back_x = win_x + 30 back_y = win_y + 30 await _step(self._click(back_x, back_y), "点击返回按钮") await _step(self._sleep(0.5), "等待返回") await _step(self._key("Escape"), "兜底关闭弹窗") await _step(self._sleep(0.2), "等待焦点稳定") # 10. 生成 local_moment_id local_moment_id = f"share_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}" return local_moment_id