"""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] (委托 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__<随机> 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