- 新增登录状态守卫后台任务 - 新增好友申请自动通过规则引擎 - 新增多分辨率UI配置与模板资源 - 新增消息拉取复合游标支持 - 优化发送队列与UI自动化逻辑 - 新增批量发送日志与错误处理 - 优化Docker镜像构建与ptrace初始化 - 新增联系人名称缓存预热
742 lines
30 KiB
Python
742 lines
30 KiB
Python
"""XdotoolDriver 门面:转发到 ui/drivers/ 子包的 5 个子 driver。
|
||
|
||
原 2375 行单类已按业务边界拆分为 5 个子 driver:
|
||
- WindowDriver : 窗口查找 / 激活 / 几何 / 截图 / 启动清场
|
||
- LoginDriver : 登录态检测 / 等待登录 / 二维码定位
|
||
- MessageDriver : 发送文本 / 文件 / 撤回 / 转发
|
||
- MomentDriver : 朋友圈图文 / 点赞 / 评论 / 删除
|
||
- ContactDriver : 通过好友申请 / 修改备注 / 添加好友 / 搜索用户
|
||
|
||
本文件仅保留门面壳,所有已拆分方法透传到对应子 driver,保持原公开
|
||
方法签名不变(路由层零改动)。
|
||
|
||
未拆分到子 driver 的 5 个方法(restart_wechat / start_wechat /
|
||
check_wechat_pid / logout / share_article_moment)暂时内联保留,
|
||
通过委托辅助方法(_run / _sleep / _key 等)调用子 driver 的底层能力。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import random
|
||
import time
|
||
from typing import Awaitable, Optional
|
||
|
||
from woc_bridge.models import BridgeError, LoginState
|
||
from woc_bridge.ui.drivers import (
|
||
BaseDriver, # noqa: F401 保留导出供外部类型检查
|
||
WindowDriver,
|
||
LoginDriver,
|
||
MessageDriver,
|
||
MomentDriver,
|
||
ContactDriver,
|
||
)
|
||
|
||
logger = logging.getLogger("woc-bridge")
|
||
|
||
# 单步骤超时下限 = L1(5s) + 2s,避免 L2 先于 L1 触发导致子进程泄漏。
|
||
# 仅供内联方法 share_article_moment 的本地 _step 闭包使用(与 base.py 同名常量对齐)。
|
||
_STEP_MIN_TIMEOUT_SEC = 7.0
|
||
|
||
# 微信可执行文件固定路径(与 docker/app-defs.sh 中 wechat 类型一致)。
|
||
# 仅 start_wechat 显式启动时使用。
|
||
_WECHAT_BIN = "/config/wechat/opt/wechat/wechat"
|
||
|
||
|
||
class XdotoolDriver:
|
||
"""XdotoolDriver 门面(原 2375 行单类已拆分到 ui/drivers/)。
|
||
|
||
构造签名保持 ``XdotoolDriver(display: str = ":1")``,路由层零改动。
|
||
内部实例化 5 个子 driver,其中 4 个注入 window_driver 引用。
|
||
|
||
所有已拆分方法透传到子 driver;未拆分的 5 个方法(进程管理 / logout /
|
||
分享文章)暂时内联保留,通过委托辅助方法调用子 driver 底层能力。
|
||
"""
|
||
|
||
def __init__(self, display: str = ":1") -> None:
|
||
"""保存 DISPLAY 并实例化 5 个子 driver。
|
||
|
||
Args:
|
||
display: X server display 地址,如 ":1"
|
||
"""
|
||
self.display = display
|
||
# 先构造 WindowDriver(无兄弟依赖)
|
||
self._window = WindowDriver(display=display)
|
||
# 其他子 driver 注入 window_driver 引用(find_wechat_window 归属 Window)
|
||
self._login = LoginDriver(display=display, window_driver=self._window)
|
||
self._message = MessageDriver(display=display, window_driver=self._window)
|
||
self._moment = MomentDriver(display=display, window_driver=self._window)
|
||
self._contact = ContactDriver(display=display, window_driver=self._window)
|
||
|
||
# P0 修复:5 个子 driver 必须共享同一把 _ui_lock。
|
||
# BaseDriver 在 backend=None 时各自创建独立 asyncio.Lock(),导致跨 driver
|
||
# 并发保护失效(如 logout 不走 send_queue 与 send_text 走 send_queue 可能
|
||
# 并发执行 xdotool 命令,造成点击焦点错乱)。统一复用 WindowDriver 的锁。
|
||
shared_ui_lock = self._window._ui_lock
|
||
self._login._ui_lock = shared_ui_lock
|
||
self._message._ui_lock = shared_ui_lock
|
||
self._moment._ui_lock = shared_ui_lock
|
||
self._contact._ui_lock = shared_ui_lock
|
||
|
||
# Actions 由 app.py 在初始化阶段回填,供 MessageDriver 复用 profile 比例坐标。
|
||
self._actions: Optional[Any] = None
|
||
|
||
def set_actions(self, actions: Any) -> None:
|
||
"""注入 L3 Actions,使 MessageDriver 可走 LocatorRegistry + profile。"""
|
||
self._actions = actions
|
||
self._message._actions = actions
|
||
|
||
# ==================================================================
|
||
# 委托辅助方法(供内联的 5 个未拆分方法调用子 driver 底层能力)
|
||
# ==================================================================
|
||
def _env(self) -> dict:
|
||
"""返回带 DISPLAY 的环境副本(委托 WindowDriver._env,来自 BaseDriver)。"""
|
||
return self._window._env()
|
||
|
||
async def _run(
|
||
self,
|
||
args: list[str],
|
||
*,
|
||
input_bytes: Optional[bytes] = None,
|
||
) -> tuple[int, bytes, bytes]:
|
||
"""执行一条命令并返回 (returncode, stdout, stderr)。
|
||
|
||
委托 WindowDriver._run,保持原 XdotoolDriver._run 的 BridgeError 语义。
|
||
"""
|
||
return await self._window._run(args, input_bytes=input_bytes)
|
||
|
||
async def _key(self, key: str, repeat: int = 1) -> None:
|
||
"""执行 xdotool key [--repeat N] <key>(委托 WindowDriver._key)。"""
|
||
await self._window._key(key, repeat=repeat)
|
||
|
||
async def _sleep(self, seconds: float) -> None:
|
||
"""asyncio.sleep 封装(委托 MessageDriver._sleep)。"""
|
||
await self._message._sleep(seconds)
|
||
|
||
async def _click(self, x: int, y: int) -> None:
|
||
"""移动鼠标到 (x, y) 并左键单击(委托 MomentDriver._click)。"""
|
||
await self._moment._click(x, y)
|
||
|
||
async def _paste_via_xclip(self, text: str) -> None:
|
||
"""xdotool type 直接输入文本(委托 MessageDriver._paste_via_xclip)。"""
|
||
await self._message._paste_via_xclip(text)
|
||
|
||
async def _open_session_by_name(
|
||
self,
|
||
name: str,
|
||
timeout_sec: float = 20.0,
|
||
) -> None:
|
||
"""通过搜索框定位会话(委托 MessageDriver._open_session_by_name)。"""
|
||
await self._message._open_session_by_name(name, timeout_sec=timeout_sec)
|
||
|
||
async def _activate_window_fast(self, window_id: Optional[int] = None) -> None:
|
||
"""激活微信窗口并校验焦点(委托 MessageDriver._activate_window_fast)。
|
||
|
||
P0 修复:routes/login.py 调用 xdotool._activate_window_fast(),
|
||
但门面未转发该方法,导致扫码登录入口 AttributeError 崩溃。
|
||
"""
|
||
await self._message._activate_window_fast(window_id)
|
||
|
||
# ==================================================================
|
||
# WindowDriver 转发
|
||
# ==================================================================
|
||
async def find_wechat_window(self) -> Optional[int]:
|
||
"""用 xdotool search --name "微信" 查找微信窗口 ID。"""
|
||
return await self._window.find_wechat_window()
|
||
|
||
async def activate_window(self, window_id: Optional[int] = None) -> None:
|
||
"""激活微信窗口。"""
|
||
await self._window.activate_window(window_id)
|
||
|
||
async def get_window_geometry(self, window_id: Optional[int] = None):
|
||
"""获取微信窗口几何信息,返回 WindowGeom(新增公开方法)。"""
|
||
return await self._window.get_window_geometry(window_id)
|
||
|
||
async def screenshot(self) -> bytes:
|
||
"""截取整个 X 屏幕为 PNG bytes(新增方法,对齐 backends 模式)。"""
|
||
return await self._window.screenshot()
|
||
|
||
async def _full_cleanup_on_startup(self) -> bool:
|
||
"""启动时全量清场(3 次重试),转发到 WindowDriver。
|
||
|
||
app.py 启动流程调用,保持私有方法签名不变。
|
||
"""
|
||
return await self._window._full_cleanup_on_startup()
|
||
|
||
# ==================================================================
|
||
# LoginDriver 转发
|
||
# ==================================================================
|
||
async def is_wechat_running(self) -> bool:
|
||
"""判断微信进程是否运行。"""
|
||
return await self._login.is_wechat_running()
|
||
|
||
async def detect_login_state(self) -> str:
|
||
"""检测登录态(启发式)。"""
|
||
return await self._login.detect_login_state()
|
||
|
||
async def wait_for_login(self, timeout_sec: float = 120.0) -> bool:
|
||
"""轮询等待微信登录完成(新增方法)。"""
|
||
return await self._login.wait_for_login(timeout_sec=timeout_sec)
|
||
|
||
async def get_qr_rect(self) -> Optional[tuple[int, int, int, int]]:
|
||
"""定位登录二维码矩形区域(新增占位,未实现返回 None)。"""
|
||
return await self._login.get_qr_rect()
|
||
|
||
# ==================================================================
|
||
# MessageDriver 转发
|
||
# ==================================================================
|
||
async def send_text(
|
||
self,
|
||
to_wxid: str,
|
||
content: str,
|
||
display_name: Optional[str] = None,
|
||
) -> str:
|
||
"""发送文本消息。"""
|
||
return await self._message.send_text(to_wxid, content, display_name=display_name)
|
||
|
||
async def send_file(
|
||
self,
|
||
to_wxid: str,
|
||
file_path: str,
|
||
is_image: bool = False,
|
||
display_name: Optional[str] = None,
|
||
) -> str:
|
||
"""发送文件/图片(MVP 简化版)。"""
|
||
return await self._message.send_file(
|
||
to_wxid, file_path, is_image=is_image, display_name=display_name
|
||
)
|
||
|
||
async def revoke_message(
|
||
self,
|
||
talker: str,
|
||
create_time: int,
|
||
display_name: Optional[str] = None,
|
||
timeout_sec: float = 15.0,
|
||
) -> bool:
|
||
"""撤回指定会话中最近发送的消息(experimental)。"""
|
||
return await self._message.revoke_message(
|
||
talker, create_time, display_name=display_name, timeout_sec=timeout_sec
|
||
)
|
||
|
||
async def forward_message(
|
||
self,
|
||
talker: str,
|
||
target_display_name: str,
|
||
source_display_name: Optional[str] = None,
|
||
timeout_sec: float = 20.0,
|
||
) -> bool:
|
||
"""转发指定会话中的消息到另一会话(experimental)。"""
|
||
return await self._message.forward_message(
|
||
talker,
|
||
target_display_name,
|
||
source_display_name=source_display_name,
|
||
timeout_sec=timeout_sec,
|
||
)
|
||
|
||
# ==================================================================
|
||
# MomentDriver 转发
|
||
# ==================================================================
|
||
async def publish_moment(
|
||
self,
|
||
content: str,
|
||
timeout_sec: float = 20.0,
|
||
) -> str:
|
||
"""发表纯文字朋友圈。"""
|
||
return await self._moment.publish_moment(content, timeout_sec=timeout_sec)
|
||
|
||
async def publish_moment_with_image(
|
||
self,
|
||
image_path: str,
|
||
content: str = "",
|
||
timeout_sec: float = 25.0,
|
||
) -> str:
|
||
"""发表带图片的朋友圈(experimental)。"""
|
||
return await self._moment.publish_moment_with_image(
|
||
image_path, content=content, timeout_sec=timeout_sec
|
||
)
|
||
|
||
async def moment_like(
|
||
self,
|
||
moment_index: int = 1,
|
||
timeout_sec: float = 20.0,
|
||
) -> bool:
|
||
"""给朋友圈点赞(experimental)。"""
|
||
return await self._moment.moment_like(moment_index=moment_index, timeout_sec=timeout_sec)
|
||
|
||
async def moment_comment(
|
||
self,
|
||
comment: str,
|
||
moment_index: int = 1,
|
||
timeout_sec: float = 20.0,
|
||
) -> bool:
|
||
"""评论朋友圈(experimental)。"""
|
||
return await self._moment.moment_comment(
|
||
comment, moment_index=moment_index, timeout_sec=timeout_sec
|
||
)
|
||
|
||
async def moment_delete(
|
||
self,
|
||
moment_index: int = 1,
|
||
timeout_sec: float = 20.0,
|
||
) -> bool:
|
||
"""删除自己的朋友圈(experimental)。"""
|
||
return await self._moment.moment_delete(moment_index=moment_index, timeout_sec=timeout_sec)
|
||
|
||
# ==================================================================
|
||
# ContactDriver 转发
|
||
# ==================================================================
|
||
async def accept_friend_request(
|
||
self,
|
||
stranger_wxid: str = "",
|
||
nickname: str = "",
|
||
timeout_sec: float = 25.0,
|
||
) -> bool:
|
||
"""通过好友申请(experimental)。"""
|
||
return await self._contact.accept_friend_request(
|
||
stranger_wxid=stranger_wxid,
|
||
nickname=nickname,
|
||
timeout_sec=timeout_sec,
|
||
)
|
||
|
||
async def set_contact_remark(
|
||
self,
|
||
talker: str,
|
||
remark: str,
|
||
display_name: Optional[str] = None,
|
||
timeout_sec: float = 15.0,
|
||
) -> bool:
|
||
"""修改好友备注名(experimental)。"""
|
||
return await self._contact.set_contact_remark(
|
||
talker, remark, display_name=display_name, timeout_sec=timeout_sec
|
||
)
|
||
|
||
async def add_friend(
|
||
self,
|
||
keyword: str,
|
||
message: str = "",
|
||
timeout_sec: float = 20.0,
|
||
) -> bool:
|
||
"""搜索并添加好友(experimental)。"""
|
||
return await self._contact.add_friend(
|
||
keyword, message=message, timeout_sec=timeout_sec
|
||
)
|
||
|
||
async def search_user(
|
||
self,
|
||
keyword: str,
|
||
timeout_sec: float = 15.0,
|
||
) -> Optional[dict]:
|
||
"""搜索用户并返回用户信息(新增占位,未实现返回 None)。"""
|
||
return await self._contact.search_user(keyword, timeout_sec=timeout_sec)
|
||
|
||
# ==================================================================
|
||
# 未拆分到子 driver 的方法(暂时内联保留,路由层有调用)
|
||
# ==================================================================
|
||
# 以下 5 个方法在 Task 2 拆分时未归入任何子 driver,但路由层
|
||
# (routes/login.py / routes/diagnostic.py / routes/moments.py) 仍在调用。
|
||
# 为保证"路由层零改动",此处保留原实现,通过委托辅助方法调用子 driver
|
||
# 底层能力。后续可由其他 Task 补充到对应子 driver。
|
||
|
||
# ------------------------------------------------------------------
|
||
# 进程管理(restart_wechat / start_wechat / check_wechat_pid)
|
||
# ------------------------------------------------------------------
|
||
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 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(
|
||
_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", _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
|
||
|
||
# ------------------------------------------------------------------
|
||
# 退出登录(routes/login.py 调用)
|
||
# ------------------------------------------------------------------
|
||
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)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 分享公众号文章到朋友圈(routes/moments.py 调用)
|
||
# ------------------------------------------------------------------
|
||
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(_STEP_MIN_TIMEOUT_SEC, 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
|