WechatOnCloud/bridge/woc_bridge/ui/xdotool_driver.py

742 lines
30 KiB
Python
Raw Normal View History

"""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