WechatOnCloud/bridge/woc_bridge/ui/xdotool_driver.py
Kris c45282f094 feat: 新增好友自动通过、UI自动化能力与多分辨率适配
- 新增登录状态守卫后台任务
- 新增好友申请自动通过规则引擎
- 新增多分辨率UI配置与模板资源
- 新增消息拉取复合游标支持
- 优化发送队列与UI自动化逻辑
- 新增批量发送日志与错误处理
- 优化Docker镜像构建与ptrace初始化
- 新增联系人名称缓存预热
2026-07-17 18:10:31 +08:00

742 lines
30 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.

"""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 只需 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 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(
_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