WechatOnCloud/bridge/woc_bridge/ui/xdotool_driver.py
Kris 102b98adea refactor: 完成项目包结构重构与基础模块搭建
本次提交将woc-bridge项目重构为模块化包结构,按职责拆分多个子域:
1. 新增models层定义所有Pydantic数据模型与统一错误体系
2. 拆分db/ui/messaging/routes等业务域模块
3. 实现基础API路由:状态查询、截图、登录、媒体获取等
4. 重构tools脚本的模块导入路径
5. 补充版本号与能力清单定义
6. 完善全局配置与依赖管理

整体完成项目从单文件脚本到可维护的包结构迁移,为后续功能开发打下基础。
2026-07-08 23:25:58 +08:00

1926 lines
75 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 Awaitable, Optional
from woc_bridge.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)
# ------------------------------------------------------------------
# 发表朋友圈(纯文字)
# ------------------------------------------------------------------
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_<unix秒>_<随机>
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_<unix秒>_<随机>
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