本次提交将woc-bridge项目重构为模块化包结构,按职责拆分多个子域: 1. 新增models层定义所有Pydantic数据模型与统一错误体系 2. 拆分db/ui/messaging/routes等业务域模块 3. 实现基础API路由:状态查询、截图、登录、媒体获取等 4. 重构tools脚本的模块导入路径 5. 补充版本号与能力清单定义 6. 完善全局配置与依赖管理 整体完成项目从单文件脚本到可维护的包结构迁移,为后续功能开发打下基础。
1926 lines
75 KiB
Python
1926 lines
75 KiB
Python
"""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:
|
||
窗口 ID(int),找不到返回 None
|
||
"""
|
||
returncode, stdout, stderr = await self._run(
|
||
["xdotool", "search", "--name", "微信"]
|
||
)
|
||
if returncode != 0:
|
||
return None
|
||
text = stdout.decode(errors="ignore").strip()
|
||
if not text:
|
||
return None
|
||
# 取第一个匹配的窗口 ID
|
||
first_line = text.splitlines()[0].strip()
|
||
try:
|
||
return int(first_line)
|
||
except ValueError:
|
||
return None
|
||
|
||
async def is_wechat_running(self) -> bool:
|
||
"""判断微信进程是否运行。
|
||
|
||
用 pgrep -x 精确匹配进程名 wechat(避免 -f 子串匹配误中
|
||
bridge 自身命令行里的 /config/.config/xwechat 路径)。
|
||
若 pgrep 不可用,回落到 xdotool search(窗口存在即视为进程运行)。
|
||
"""
|
||
# 用 -x 精确进程名匹配,避免 -f 子串误中 bridge 自身
|
||
# 微信 4.0 Linux 进程名通常为 "wechat"
|
||
for name in ("wechat", "WeChat"):
|
||
returncode, stdout, _ = await self._run(["pgrep", "-x", name])
|
||
if returncode == 0 and stdout.decode(errors="ignore").strip():
|
||
return True
|
||
# 回落:窗口存在即视为运行
|
||
window_id = await self.find_wechat_window()
|
||
return window_id is not None
|
||
|
||
async def activate_window(self, window_id: Optional[int] = None) -> None:
|
||
"""激活微信窗口。
|
||
|
||
Args:
|
||
window_id: 指定窗口 ID;为 None 时自动查找
|
||
"""
|
||
if window_id is None:
|
||
window_id = await self.find_wechat_window()
|
||
if window_id is None:
|
||
raise BridgeError(
|
||
code="WINDOW_NOT_FOUND",
|
||
message="未找到微信窗口,无法激活",
|
||
)
|
||
await self._run(["xdotool", "windowactivate", "--sync", str(window_id)])
|
||
|
||
async def _activate_window_fast(self, window_id: Optional[int] = None) -> None:
|
||
"""非阻塞激活微信窗口,避免 --sync 在 VNC 无人操作时死等。"""
|
||
if window_id is None:
|
||
window_id = await self.find_wechat_window()
|
||
if window_id is None:
|
||
raise BridgeError(
|
||
code="WINDOW_NOT_FOUND",
|
||
message="未找到微信窗口,无法激活",
|
||
)
|
||
await self._run(["xdotool", "windowactivate", str(window_id)])
|
||
|
||
async def restart_wechat(self, timeout_sec: int = 30) -> int:
|
||
"""重启微信进程:发 SIGTERM 让微信优雅退出,等待 autostart 拉起新进程。
|
||
|
||
微信由容器内 autostart 脚本常驻拉起(while true 循环 + sleep 2),
|
||
故 bridge 只需 kill,autostart 会在 2 秒后自动重启。不强制 SIGKILL,
|
||
给微信优雅退出机会(避免 DB 写入未刷盘)。
|
||
|
||
Args:
|
||
timeout_sec: 等待新进程出现的最大秒数,默认 30
|
||
|
||
Returns:
|
||
新进程的 PID
|
||
|
||
Raises:
|
||
BridgeError(RESTART_TIMEOUT): 超时未检测到新进程
|
||
"""
|
||
# 1. 获取当前 PID(若有)
|
||
returncode, stdout, _ = await self._run(["pgrep", "-x", "wechat"])
|
||
old_pids: set[int] = set()
|
||
if returncode == 0:
|
||
for line in stdout.decode(errors="ignore").splitlines():
|
||
line = line.strip()
|
||
if line:
|
||
try:
|
||
old_pids.add(int(line))
|
||
except ValueError:
|
||
pass
|
||
|
||
# 2. 若有旧进程,发 SIGTERM
|
||
for pid in old_pids:
|
||
# 用 kill(shell 内建)发 SIGTERM,不强制 SIGKILL
|
||
await self._run(["kill", "-TERM", str(pid)])
|
||
|
||
# 3. 等待新进程出现(autostart 会在 2 秒后拉起)
|
||
deadline = time.monotonic() + timeout_sec
|
||
while time.monotonic() < deadline:
|
||
await self._sleep(1.0)
|
||
rc, stdout, _ = await self._run(["pgrep", "-x", "wechat"])
|
||
if rc == 0:
|
||
for line in stdout.decode(errors="ignore").splitlines():
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
new_pid = int(line)
|
||
except ValueError:
|
||
continue
|
||
# 确认是新 PID(不在旧 PID 集合中)
|
||
if new_pid not in old_pids:
|
||
return new_pid
|
||
|
||
# 4. 超时
|
||
raise BridgeError(
|
||
code="RESTART_TIMEOUT",
|
||
message=f"等待微信重启超时({timeout_sec}s)",
|
||
)
|
||
|
||
async def detect_login_state(self) -> str:
|
||
"""检测登录态(启发式)。
|
||
|
||
MVP 阶段判定逻辑:
|
||
- 微信进程未运行 → not_running
|
||
- 进程运行但窗口未找到 → not_logged_in
|
||
- 窗口存在 → logged_in
|
||
|
||
Returns:
|
||
LoginState 枚举值(字符串)
|
||
"""
|
||
running = await self.is_wechat_running()
|
||
if not running:
|
||
return LoginState.NOT_RUNNING.value
|
||
window_id = await self.find_wechat_window()
|
||
if window_id is None:
|
||
return LoginState.NOT_LOGGED_IN.value
|
||
# MVP:窗口存在即视为已登录
|
||
return LoginState.LOGGED_IN.value
|
||
|
||
# ------------------------------------------------------------------
|
||
# 显式启动微信(供 diagnostic autofix 使用)
|
||
# ------------------------------------------------------------------
|
||
# 微信可执行文件固定路径(与 docker/app-defs.sh 中 wechat 类型一致)。
|
||
# 仅对 wechat 类型实例有意义;Telegram/Chromium/自定义应用的生命周期
|
||
# 不应通过 bridge autofix 干预。
|
||
_WECHAT_BIN = "/config/wechat/opt/wechat/wechat"
|
||
|
||
async def start_wechat(self, timeout_sec: int = 10) -> Optional[int]:
|
||
"""显式启动微信进程,返回新 PID 或 None。
|
||
|
||
用于 diagnostic autofix:pkill 后 autostart watchdog 未拉起时,
|
||
bridge 显式启动作为兜底。避免引入 shell 依赖,直接执行二进制。
|
||
|
||
竞态规避:
|
||
1. 启动前再次 pgrep,已有进程则直接返回(不重复启动)
|
||
2. 启动后轮询 pgrep,等待新 PID 出现
|
||
|
||
Args:
|
||
timeout_sec: 等待新进程出现的最大秒数,默认 10
|
||
|
||
Returns:
|
||
新进程 PID;启动失败或超时返回 None
|
||
"""
|
||
# 1. 启动前再 pgrep,已有则跳过(避免与 autostart 竞态)
|
||
existing_pid = await self.check_wechat_pid()
|
||
if existing_pid is not None:
|
||
return existing_pid
|
||
|
||
# 2. 显式启动(disown 语义:不等待进程结束)
|
||
# DISPLAY/XAUTHORITY 由 _env() 透传,确保 wechat 能连接 X
|
||
try:
|
||
await asyncio.create_subprocess_exec(
|
||
self._WECHAT_BIN,
|
||
stdout=asyncio.subprocess.DEVNULL,
|
||
stderr=asyncio.subprocess.DEVNULL,
|
||
env=self._env(),
|
||
# 启动后立即 detach,不让 wechat 成为 bridge 的子进程
|
||
# (避免 bridge 退出时连带杀掉 wechat)
|
||
start_new_session=True,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("start_wechat: 启动 %s 失败: %r", self._WECHAT_BIN, e)
|
||
return None
|
||
|
||
# 3. 等待新 PID 出现(不依赖 subprocess 返回的 pid,因为 wechat
|
||
# 可能 fork 出独立进程,pgrep 比依赖 proc.pid 更可靠)
|
||
deadline = time.monotonic() + timeout_sec
|
||
while time.monotonic() < deadline:
|
||
await self._sleep(1.0)
|
||
new_pid = await self.check_wechat_pid()
|
||
if new_pid is not None:
|
||
return new_pid
|
||
|
||
logger.warning("start_wechat: 等待 %ss 内未检测到新进程", timeout_sec)
|
||
return None
|
||
|
||
async def check_wechat_pid(self) -> Optional[int]:
|
||
"""返回当前运行的微信主进程 PID,无则返回 None。"""
|
||
returncode, stdout, _ = await self._run(["pgrep", "-x", "wechat"])
|
||
if returncode != 0:
|
||
return None
|
||
for line in stdout.decode(errors="ignore").splitlines():
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
return int(line)
|
||
except ValueError:
|
||
continue
|
||
return None
|
||
|
||
# ------------------------------------------------------------------
|
||
# 剪贴板粘贴
|
||
# ------------------------------------------------------------------
|
||
async def _paste_via_xclip(self, text: str) -> None:
|
||
"""通过 xclip 写入剪贴板并触发 Ctrl+V 粘贴。
|
||
|
||
直接把原始字节写入 xclip stdin(create_subprocess_exec 不经 shell,
|
||
无转义问题,因此无需 base64 编码)。并行等待 stdin 写入与进程退出,
|
||
避免大文本时管道缓冲阻塞导致的死锁。
|
||
|
||
Args:
|
||
text: 待粘贴文本
|
||
|
||
Raises:
|
||
BridgeError(SEND_FAILED): xclip 退出码非 0
|
||
"""
|
||
xclip_proc = await asyncio.create_subprocess_exec(
|
||
"xclip", "-selection", "clipboard",
|
||
stdin=asyncio.subprocess.PIPE,
|
||
stdout=asyncio.subprocess.DEVNULL,
|
||
stderr=asyncio.subprocess.PIPE,
|
||
env=self._env(),
|
||
)
|
||
assert xclip_proc.stdin is not None
|
||
|
||
async def _feed() -> None:
|
||
try:
|
||
xclip_proc.stdin.write(text.encode("utf-8"))
|
||
await xclip_proc.stdin.drain()
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
# xclip 已退出,忽略
|
||
pass
|
||
finally:
|
||
try:
|
||
xclip_proc.stdin.close()
|
||
except Exception:
|
||
pass
|
||
|
||
# 并行:喂 stdin + 等进程退出,避免管道缓冲满死锁
|
||
_, (_, stderr) = await asyncio.gather(_feed(), xclip_proc.communicate())
|
||
if xclip_proc.returncode != 0:
|
||
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
|
||
raise BridgeError(
|
||
code="SEND_FAILED",
|
||
message=f"xclip 写入剪贴板失败 (code={xclip_proc.returncode}): {err}",
|
||
)
|
||
# 触发粘贴
|
||
await self._key("ctrl+v")
|
||
|
||
# ------------------------------------------------------------------
|
||
# 会话定位(Ctrl+F 搜索)
|
||
# ------------------------------------------------------------------
|
||
async def _open_session_by_name(
|
||
self,
|
||
name: str,
|
||
timeout_sec: float = 10.0,
|
||
) -> None:
|
||
"""通过微信搜索框定位并进入指定联系人的会话。
|
||
|
||
流程:
|
||
1. 非阻塞激活微信窗口
|
||
2. 按 Esc 关闭可能存在的搜索框/弹窗
|
||
3. Ctrl+F 打开搜索
|
||
4. Ctrl+A 全选旧内容,粘贴 display_name
|
||
5. 等待搜索结果渲染
|
||
6. 按 ↓ 选中第一个结果,回车进入会话
|
||
7. 再按 Esc 确保退出搜索模式,焦点落在聊天输入框
|
||
|
||
Args:
|
||
name: 用于搜索的显示名(备注/昵称/微信号)
|
||
timeout_sec: 整体超时(秒),默认 10 秒
|
||
|
||
Raises:
|
||
BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口
|
||
BridgeError(SEND_FAILED): 超时或 xdotool/xclip 操作失败
|
||
"""
|
||
if not name:
|
||
raise BridgeError(
|
||
code="SEND_FAILED",
|
||
message="display_name 不能为空,无法定位会话",
|
||
)
|
||
|
||
deadline = time.monotonic() + timeout_sec
|
||
|
||
async def _step(coro: Awaitable[None], desc: str) -> None:
|
||
"""执行单个 UI 步骤并加超时保护。"""
|
||
remaining = deadline - time.monotonic()
|
||
if remaining <= 0:
|
||
raise BridgeError(
|
||
code="SEND_FAILED",
|
||
message=f"定位会话超时: {desc}",
|
||
)
|
||
try:
|
||
await asyncio.wait_for(coro, timeout=max(1.0, remaining))
|
||
except asyncio.TimeoutError as exc:
|
||
raise BridgeError(
|
||
code="SEND_FAILED",
|
||
message=f"定位会话步骤超时: {desc}",
|
||
) from exc
|
||
|
||
# 1. 激活窗口(非阻塞,避免 --sync 死等)
|
||
window_id = await self.find_wechat_window()
|
||
if window_id is None:
|
||
raise BridgeError(
|
||
code="WINDOW_NOT_FOUND",
|
||
message="未找到微信窗口,无法定位会话",
|
||
)
|
||
await _step(self._activate_window_fast(window_id), "激活窗口")
|
||
await _step(self._sleep(0.3), "等待窗口激活")
|
||
|
||
# 2. 关闭可能存在的搜索框/弹窗
|
||
await _step(self._key("Escape"), "关闭搜索框")
|
||
await _step(self._sleep(0.2), "等待 Esc 生效")
|
||
|
||
# 3. 打开搜索
|
||
await _step(self._key("ctrl+f"), "打开搜索")
|
||
await _step(self._sleep(0.4), "等待搜索框打开")
|
||
|
||
# 4. 清空并输入搜索关键词
|
||
await _step(self._key("ctrl+a"), "全选搜索框内容")
|
||
await _step(self._sleep(0.1), "等待全选")
|
||
await _step(self._paste_via_xclip(name), "粘贴搜索关键词")
|
||
await _step(self._sleep(0.8), "等待搜索结果")
|
||
|
||
# 5. 选中第一个结果并进入会话
|
||
await _step(self._key("Down"), "选中搜索结果")
|
||
await _step(self._sleep(0.3), "等待选中")
|
||
await _step(self._key("Return"), "进入会话")
|
||
await _step(self._sleep(0.6), "等待会话打开")
|
||
|
||
# 6. 退出搜索模式,确保焦点在输入框
|
||
await _step(self._key("Escape"), "退出搜索模式")
|
||
await _step(self._sleep(0.2), "等待焦点稳定")
|
||
|
||
# ------------------------------------------------------------------
|
||
# 发送文本
|
||
# ------------------------------------------------------------------
|
||
async def send_text(
|
||
self,
|
||
to_wxid: str,
|
||
content: str,
|
||
display_name: Optional[str] = None,
|
||
) -> str:
|
||
"""发送文本消息。
|
||
|
||
流程:
|
||
1. 通过微信搜索框定位会话(使用 display_name,未提供时回退到 to_wxid)
|
||
2. 粘贴文本内容并回车发送
|
||
3. 返回本地生成的 channel_msg_id
|
||
|
||
Args:
|
||
to_wxid: 目标 wxid
|
||
content: 文本内容
|
||
display_name: 用于搜索定位会话的显示名(备注/昵称/微信号)
|
||
|
||
Returns:
|
||
本地生成的 channel_msg_id,格式 local_<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
|