"""消息子 driver:发送文本 / 发送文件 / 撤回 / 转发。 从 xdotool_driver.py 单类中拆出的消息域方法,继承 BaseDriver 复用公共能力。 find_wechat_window 归属 WindowDriver,本 driver 通过构造函数注入 window_driver 引用调用。 方法清单(与原 XdotoolDriver 签名一致): - send_text(to_wxid, content, display_name=None) -> str - send_file(to_wxid, file_path, is_image=False, display_name=None) -> str (保持原 placeholder 逻辑) - revoke_message(talker, create_time, display_name=None, timeout_sec=15.0) -> bool - forward_message(talker, target_display_name, source_display_name=None, timeout_sec=20.0) -> bool """ from __future__ import annotations import asyncio import logging import os import random import time from typing import Any, Awaitable, Optional, TYPE_CHECKING from woc_bridge.models import BridgeError from woc_bridge.ui.drivers.base import BaseDriver if TYPE_CHECKING: from woc_bridge.ui.drivers.window import WindowDriver logger = logging.getLogger("woc-bridge") # --------------------------------------------------------------------------- # 分层超时与布局常量(与 xdotool_driver.py 保持一致) # --------------------------------------------------------------------------- # send_text 中"粘贴内容 + 短暂等待 + 回车发送"三步整体的超时(秒)。 _SEND_BODY_TIMEOUT_SEC = 15.0 # 微信 4.x 自绘 UI 布局常量(基于窗口几何的相对坐标) _LEFT_PANEL_WIDTH = 300 # 左侧会话列表宽度(像素) _SEARCH_BOX_Y_OFFSET = 55 # 搜索框中心相对窗口顶部的 Y 偏移 _SEARCH_RESULT_Y_OFFSET = 120 # 搜索结果/会话列表第一项中心相对窗口顶部的 Y 偏移 _INPUT_BOX_Y_OFFSET = 50 # 输入框中心相对窗口底部的 Y 偏移(向上) _SEND_BUTTON_X_OFFSET = 60 # 发送按钮中心相对窗口右边缘的 X 偏移(向左) _SEND_BUTTON_Y_OFFSET = 30 # 发送按钮中心相对窗口底部的 Y 偏移(向上) class MessageDriver(BaseDriver): """消息子 driver。""" def __init__( self, display: str, backend=None, window_driver: Optional["WindowDriver"] = None, ) -> None: super().__init__(display, backend) # 注入兄弟 driver:_open_session_by_name / revoke / forward 依赖 # WindowDriver.find_wechat_window self._window_driver = window_driver # 调试截图:环境变量控制开关(与原 XdotoolDriver.__init__ 一致) self._debug_shots_enabled = os.environ.get( "WOC_UI_DEBUG_SHOTS", "false" ).lower() == "true" self._debug_queue: asyncio.Queue = asyncio.Queue() self._debug_task: Optional[asyncio.Task] = None # ------------------------------------------------------------------ # 命令执行包装(保持与原 XdotoolDriver._run 语义一致) # ------------------------------------------------------------------ async def _run( self, args: list[str], *, input_bytes: Optional[bytes] = None, ) -> tuple[int, bytes, bytes]: """执行一条命令并返回 (returncode, stdout, stderr)。 委托 BaseDriver._run_managed,TimeoutError 转 BridgeError(code=SEND_FAILED)。 """ try: return await self._run_managed(args) except asyncio.TimeoutError as exc: logger.error("[ui] command timeout: %s", args[0] if args else "?") raise BridgeError( code="SEND_FAILED", message=f"命令超时: {args[0] if args else '?'}", ) from exc async def _key(self, key: str, repeat: int = 1) -> None: """执行 xdotool key [--repeat N] 。""" logger.info("[ui] key: %s repeat=%d", key, repeat) args = ["xdotool", "key"] if repeat > 1: args.extend(["--repeat", str(repeat)]) args.append(key) rc, _, _ = await self._run(args) logger.info("[ui] key %s -> rc=%s", key, rc) async def _sleep(self, seconds: float) -> None: """asyncio.sleep 封装,便于测试与统一调速。""" logger.info("[ui] sleep %.2fs", seconds) await asyncio.sleep(seconds) async def _paste_via_xclip(self, text: str) -> None: """通过 xdotool type 直接向聚焦控件输入文本。 历史背景:原实现用 xclip 写剪贴板 + Ctrl+V 粘贴,但微信 4.x Linux 自绘 UI 不响应 X11 core keyboard 的修饰键组合(Ctrl+V),只响应单 字符键盘事件。xev 验证 KeyPress 事件确实到达窗口但微信不处理, 而 xdotool type(逐字符 XSendEvent)能正常输入中英文。因此改用 xdotool type 直接打字。方法名保留 _paste_via_xclip 以维持调用点稳定。 Raises: BridgeError(SEND_FAILED): xdotool type 执行失败 """ logger.info("[ui] type text: len=%d", len(text)) rc, _, stderr = await self._run(["xdotool", "type", text]) if rc != 0: err = stderr.decode(errors="ignore").strip() if stderr else "unknown" raise BridgeError( code="SEND_FAILED", message=f"xdotool type 失败 (code={rc}): {err}", ) logger.info("[ui] type done rc=%s", rc) 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 秒后释放。 用于触发微信消息长按菜单(撤回/转发/复制等)。 用 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 _debug_screenshot(self, step: str, trace_id: str = "") -> None: """入队一张调试截图,由后台 _debug_writer 异步写盘。""" if not self._debug_shots_enabled: return await self._debug_queue.put((step, trace_id, time.time())) if self._debug_task is None or self._debug_task.done(): self._debug_task = asyncio.create_task(self._debug_writer()) async def _debug_writer(self) -> None: """后台消费 _debug_queue,将截图请求异步写盘。""" while True: step, trace_id, ts = await self._debug_queue.get() try: os.makedirs("/tmp/woc_debug", exist_ok=True) path = f"/tmp/woc_debug/{trace_id}_{step}_{int(ts * 1000)}.png" rc, _, stderr = await self._run(["scrot", path]) if rc == 0: logger.info("[ui] debug screenshot saved: %s", path) else: err = stderr.decode(errors="ignore").strip() if stderr else "unknown" logger.warning("[ui] debug screenshot failed (%s): %s", step, err) except Exception as e: logger.warning("[ui] debug writer 异常 (step=%s): %r", step, e) finally: self._debug_queue.task_done() # ------------------------------------------------------------------ # 输入框 / 发送按钮定位 # ------------------------------------------------------------------ async def _click_input_box(self) -> None: """鼠标点击聊天输入框,强制焦点落到输入框。""" win_x, win_y, win_w, win_h = await self._get_window_geometry() input_x = win_x + _LEFT_PANEL_WIDTH + (win_w - _LEFT_PANEL_WIDTH) // 2 input_y = win_y + win_h - _INPUT_BOX_Y_OFFSET await self._click_at(input_x, input_y) await self._sleep(0.3) async def _click_send_button(self) -> None: """鼠标点击右下角发送按钮,替代可能失效的 Return 键。""" win_x, win_y, win_w, win_h = await self._get_window_geometry() send_x = win_x + win_w - _SEND_BUTTON_X_OFFSET send_y = win_y + win_h - _SEND_BUTTON_Y_OFFSET await self._click_at(send_x, send_y) # ------------------------------------------------------------------ # 会话定位(鼠标点击搜索框 + 点击结果) # ------------------------------------------------------------------ async def _open_session_by_name( self, name: str, timeout_sec: float = 20.0, ) -> None: """通过微信左侧搜索框定位并进入指定联系人的会话。 WeChat 4.x 自绘 UI 对 Ctrl+F 等 X11 修饰键组合不响应,全部改用 鼠标点击: 1. 激活窗口并校验焦点 2. 按 Esc 关闭可能存在的搜索框/弹窗 3. 点击左侧顶部搜索框 4. 输入 display_name 5. 等待搜索结果渲染 6. 点击第一个搜索结果进入会话 7. 按 Esc 退出搜索覆盖层 Args: name: 用于搜索的显示名(备注/昵称/微信号) timeout_sec: 整体超时(秒) Raises: BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口 BridgeError(SEND_FAILED): 超时或 xdotool 操作失败 """ if not name: raise BridgeError( code="SEND_FAILED", message="display_name 不能为空,无法定位会话", ) deadline = time.monotonic() + timeout_sec # 1. 查找并激活窗口(委托 WindowDriver 查窗口) window_id = await self._window_driver.find_wechat_window() logger.info("[ui] found window_id=%s", window_id) if window_id is None: raise BridgeError( code="WINDOW_NOT_FOUND", message="未找到微信窗口,无法定位会话", ) await self._step(self._activate_window_fast(window_id), "激活窗口", deadline) await self._step(self._sleep(1.0), "等待窗口激活", deadline) # 2. 关闭可能存在的搜索框/弹窗 await self._step(self._key("Escape"), "关闭搜索框/弹窗", deadline) await self._step(self._sleep(1.0), "等待 Esc 生效", deadline) # 3. 获取窗口几何,计算搜索框/结果坐标 win_x, win_y, win_w, win_h = await self._get_window_geometry() search_x = win_x + _LEFT_PANEL_WIDTH // 2 search_y = win_y + _SEARCH_BOX_Y_OFFSET result_x = win_x + _LEFT_PANEL_WIDTH // 2 result_y = win_y + _SEARCH_RESULT_Y_OFFSET # 4. 点击搜索框并输入关键词 await self._step(self._click_at(search_x, search_y), "点击搜索框", deadline) await self._step(self._sleep(1.0), "等待搜索框聚焦", deadline) # 清空可能存在的旧内容:用多次 Backspace 规避 Ctrl+A 可能失效的问题 await self._step(self._key("BackSpace", repeat=30), "清空搜索框旧内容", deadline) await self._step(self._sleep(1.0), "等待清空", deadline) await self._step(self._paste_via_xclip(name), "输入搜索关键词", deadline) await self._step(self._sleep(1.0), "等待搜索结果", deadline) # 5. 点击第一个搜索结果进入会话 await self._step(self._click_at(result_x, result_y), "点击第一个搜索结果", deadline) await self._step(self._sleep(1.0), "等待会话打开", deadline) await self._debug_screenshot("after_open_session") # 6. 退出搜索模式,确保焦点在会话区域 await self._step(self._key("Escape"), "退出搜索模式", deadline) await self._step(self._sleep(1.0), "等待焦点稳定", deadline) async def _activate_window_fast(self, window_id: Optional[int] = None) -> None: """激活微信窗口并校验焦点是否真正落到该窗口上。 先用非阻塞 windowactivate 避免 --sync 在 VNC 无人操作时死等, 然后轮询 getactivewindow 最多 2 秒确认目标窗口已获得焦点。 """ if window_id is None: window_id = await self._window_driver.find_wechat_window() if window_id is None: raise BridgeError( code="WINDOW_NOT_FOUND", message="未找到微信窗口,无法激活", ) await self._run(["xdotool", "windowactivate", str(window_id)]) # 轮询校验焦点(最多 2 秒,步进 100ms) deadline = time.monotonic() + 2.0 while time.monotonic() < deadline: rc, stdout, _ = await self._run(["xdotool", "getactivewindow"]) if rc == 0: active_text = stdout.decode(errors="ignore").strip() try: if int(active_text) == window_id: return except ValueError: pass await self._sleep(0.1) logger.warning( "[ui] 窗口激活校验失败: window_id=%s 未在 2s 内成为活动窗口", window_id ) async def _get_window_geometry(self) -> tuple[int, int, int, int]: """获取微信窗口几何信息,返回 (win_x, win_y, win_w, win_h)。 复用 BaseDriver._parse_window_geometry 消除内联解析重复。 """ window_id = await self._window_driver.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 = self._parse_window_geometry(stdout.decode(errors="ignore")) if geom is None: raise BridgeError( code="SEND_FAILED", message="解析窗口几何信息失败", ) return (geom.x, geom.y, geom.width, geom.height) # ------------------------------------------------------------------ # 发送文本 # ------------------------------------------------------------------ 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__<随机> """ name = display_name if display_name else to_wxid t_total = time.perf_counter() logger.info("[send_text] 开始 to=%s name=%s content_len=%d", to_wxid, name, len(content)) # 1. 定位会话(_open_session_by_name 内部已含整体超时) t0 = time.perf_counter() await self._open_session_by_name(name) logger.info("[send_text] 会话定位完成 (%.0fms)", (time.perf_counter() - t0) * 1000) await self._debug_screenshot("after_session") # 1.5 鼠标点击聊天输入框强制焦点 await self._click_input_box() await self._debug_screenshot("after_click_input") # 2. 输入内容并发送(三步整体超时保护) async def _send_body() -> None: await self._paste_via_xclip(content) logger.info("[send_text] 文本已输入") await self._debug_screenshot("after_type") await self._sleep(1.0) # 优先用鼠标点击右下角发送按钮,规避 Return 键可能不触发发送的问题 await self._click_send_button() try: t0 = time.perf_counter() await asyncio.wait_for(_send_body(), timeout=_SEND_BODY_TIMEOUT_SEC) logger.info("[send_text] 发送按钮已点击 (%.0fms)", (time.perf_counter() - t0) * 1000) except asyncio.TimeoutError as exc: logger.error("[send_text] 输入/发送步骤超时") raise BridgeError( code="SEND_FAILED", message="文本输入/发送步骤超时", ) from exc await self._sleep(1.0) await self._debug_screenshot("after_send") # 3. 按 Esc 退出当前聊天界面,回到会话列表,避免影响下一次发送 await self._key("Escape") await self._sleep(1.0) await self._debug_screenshot("after_escape") # 4. 生成 local_send_id(本地 ID,非微信原生 msg_id) local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}" logger.info("[send_text] 完成 local_send_id=%s (总耗时 %.0fms)", local_send_id, (time.perf_counter() - t_total) * 1000) return local_send_id # ------------------------------------------------------------------ # 发送文件 / 图片(MVP placeholder,保持原逻辑,真实文件传输留给 Task 7) # ------------------------------------------------------------------ async def send_file( self, to_wxid: str, file_path: str, is_image: bool = False, display_name: Optional[str] = None, ) -> str: """发送文件/图片(MVP 简化版,保持原 placeholder 逻辑)。 MVP 策略:检查文件可读 → 进入会话(复用 _open_session_by_name) → 发送"[图片/文件] 文件名"提示文本。真实文件传输机制留给后续完善 (Task 7 的 SendFileFlow 处理),此处方法签名与原 XdotoolDriver 一致。 Args: to_wxid: 目标 wxid file_path: 容器内文件绝对路径 is_image: 是否为图片 display_name: 用于搜索定位会话的显示名 Returns: 本地生成的 channel_msg_id,格式 local__<随机> 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) # 1.5 鼠标点击聊天输入框强制焦点(与 send_text 同理) await self._click_input_box() # 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 # ------------------------------------------------------------------ # 撤回消息(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. 获取窗口几何,估算最近一条自己发送的消息气泡位置 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 分钟撤回时限 """ # create_time 必须有效,避免绕过时限校验 if create_time <= 0: raise BridgeError( code="INVALID_PARAMS", message=f"create_time 必须 > 0,收到 {create_time}", ) # 撤回时间窗校验(2 分钟 = 120 秒,Task 11 收紧到 110s 留 10s UI 操作余量) now = int(time.time()) if now - create_time > 110: raise BridgeError( code="REVOKE_WINDOW_EXPIRED", message=f"消息发送于 {now - create_time}s 前,超过 110s 撤回时限(已为 UI 操作预留 10s 余量)", ) deadline = time.monotonic() + timeout_sec # 1. 定位会话(display_name 优先,回退到 wxid) search_name = display_name or talker await self._step(self._open_session_by_name(search_name), "定位会话", deadline) # 2. 获取窗口几何 window_id = await self._window_driver.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 = self._parse_window_geometry(stdout.decode(errors="ignore")) if geom is None: raise BridgeError( code="SEND_FAILED", message="解析窗口几何信息失败", ) win_x, win_y, win_w, win_h = geom.x, geom.y, geom.width, geom.height # 3. 估算最近一条消息气泡位置并长按 msg_x = win_x + win_w * 3 // 4 msg_y = win_y + win_h - 150 await self._step(self._long_press(msg_x, msg_y, 1.5), "长按消息", deadline) await self._step(self._sleep(0.6), "等待菜单弹出", deadline) # 4. 在菜单中查找"撤回"项 await self._step(self._key("Down"), "导航到撤回项", deadline) await self._step(self._sleep(0.2), "等待选中", deadline) await self._step(self._key("Down"), "导航到撤回项", deadline) await self._step(self._sleep(0.2), "等待选中", deadline) await self._step(self._key("Return"), "选择撤回", deadline) # 5. 可能有确认对话框 await self._step(self._sleep(0.5), "等待确认对话框", deadline) await self._step(self._key("Return"), "确认撤回", deadline) await self._step(self._sleep(0.5), "等待撤回完成", deadline) 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) """ deadline = time.monotonic() + timeout_sec # 1. 定位源会话(source_display_name 优先,回退到 wxid) search_name = source_display_name or talker await self._step(self._open_session_by_name(search_name), "定位源会话", deadline) # 2. 获取窗口几何 window_id = await self._window_driver.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 = self._parse_window_geometry(stdout.decode(errors="ignore")) if geom is None: raise BridgeError( code="SEND_FAILED", message="解析窗口几何信息失败", ) win_x, win_y, win_w, win_h = geom.x, geom.y, geom.width, geom.height # 3. 长按最近消息 msg_x = win_x + win_w // 2 msg_y = win_y + win_h - 150 await self._step(self._long_press(msg_x, msg_y, 1.5), "长按消息", deadline) await self._step(self._sleep(0.6), "等待菜单弹出", deadline) # 4. 选择"转发"(菜单项通常在"复制"附近) await self._step(self._key("Down"), "导航到转发项", deadline) await self._step(self._sleep(0.2), "等待选中", deadline) await self._step(self._key("Return"), "选择转发", deadline) await self._step(self._sleep(1.0), "等待转发界面打开", deadline) # 5. 在转发界面搜索目标 await self._step(self._key("ctrl+f"), "打开转发搜索", deadline) await self._step(self._sleep(0.4), "等待搜索框", deadline) await self._step(self._paste_via_xclip(target_display_name), "粘贴目标名称", deadline) await self._step(self._sleep(0.8), "等待搜索结果", deadline) # 6. 选中第一个结果 await self._step(self._key("Down"), "选中目标", deadline) await self._step(self._sleep(0.3), "等待选中", deadline) await self._step(self._key("Return"), "进入选择", deadline) # 7. 确认转发 await self._step(self._sleep(0.5), "等待确认按钮", deadline) await self._step(self._key("Return"), "确认转发", deadline) await self._step(self._sleep(1.0), "等待转发完成", deadline) logger.info( "forward_message: talker=%s target=%s → UI 操作完成", talker, target_display_name, ) return True # ------------------------------------------------------------------ # Task 11: post_verify 校验方法(UI 操作成功后校验 DB,返回 verified bool) # ------------------------------------------------------------------ async def post_verify_revoke_message( self, db_reader: Any, talker: str, create_time: int, sleep_sec: float = 1.5, timeout_sec: float = 3.0, ) -> bool: """校验撤回是否生效(Task 11)。 sleep 1.5s 后轮询 DB 3s(间隔 1s),查询 talker 会话中 create_time 之后的消息,成功判定 local_type in (10000, 10002) 或 content 含 "撤回了一条消息"。 Args: db_reader: DbReader 实例(可为 None,此时跳过返回 True) talker: 会话对方 wxid create_time: 原消息发送时间戳(用于建立 DB 查询游标) sleep_sec: 首次查询前等待秒数 timeout_sec: 轮询超时秒数 Returns: True 表示检测到撤回系统消息;False 表示超时未检测到 """ if db_reader is None: logger.warning( "post_verify_revoke_message: db_reader 为 None,跳过校验 (talker=%s)", talker, ) return True await asyncio.sleep(sleep_sec) deadline = time.monotonic() + timeout_sec while time.monotonic() < deadline: try: # 查 create_time 之后的新消息(撤回系统消息 local_type=10000/10002) result = await asyncio.to_thread( db_reader.get_messages_by_session, talker, cursor=create_time, limit=5, direction="after", ) for msg in result.get("messages", []): local_type = int(msg.get("type", 0)) content = msg.get("content", "") or "" if local_type in (10000, 10002) or "撤回了一条消息" in content: logger.info( "post_verify_revoke_message: 检测到撤回标记 (type=%d)", local_type, ) return True except Exception as exc: logger.warning( "post_verify_revoke_message: DB 查询异常 (talker=%s): %s", talker, exc, ) await asyncio.sleep(1.0) logger.warning( "post_verify_revoke_message: 超时 %.1fs 未检测到撤回标记 (talker=%s)", timeout_sec, talker, ) return False async def _resolve_wxid_by_display_name( self, db_reader: Any, display_name: str, ) -> str: """通过显示名解析联系人 wxid(post_verify 共用 helper)。 用 get_contacts(keyword=display_name) 模糊查 contact 表,优先精确匹配 nickname/remark,否则取第一条。解析失败返回空字符串。 Args: db_reader: DbReader 实例(可为 None,返回 "") display_name: 联系人显示名 Returns: 解析到的 wxid;失败返回 "" """ if db_reader is None or not display_name: return "" try: result = await asyncio.to_thread( db_reader.get_contacts, display_name, 5 ) contacts = result.get("contacts", []) if result else [] if contacts: # 优先精确匹配 nickname/remark,否则取第一条 for c in contacts: if c.get("nickname") == display_name or c.get("remark") == display_name: return c.get("wxid", "") return contacts[0].get("wxid", "") except Exception as exc: logger.warning( "_resolve_wxid_by_display_name: 解析 wxid 异常 (display_name=%s): %s", display_name, exc, ) return "" async def capture_forward_baseline( self, db_reader: Any, target_display_name: str, ) -> tuple[int, int]: """捕获转发目标会话的消息基线(UI 操作前调用)。 P0 修复:post_verify_forward_message 原实现无基线游标,查目标会话最近 5 条 type=49 消息时会把历史转发消息误报为新转发。本方法在 UI 操作前 查询目标会话当前最新消息的 (create_time, local_id) 作为基线,供 post_verify 用 direction="after" 只查 UI 操作后的新消息。 Args: db_reader: DbReader 实例(可为 None) target_display_name: 转发目标联系人的显示名 Returns: (max_create_time, max_local_id);db_reader 为 None / wxid 解析失败 / 会话无消息时返回 (0, 0) """ if db_reader is None: return (0, 0) target_wxid = await self._resolve_wxid_by_display_name( db_reader, target_display_name ) if not target_wxid: logger.warning( "capture_forward_baseline: 无法解析目标 wxid (target=%s),基线=(0,0)", target_display_name, ) return (0, 0) try: result = await asyncio.to_thread( db_reader.get_messages_by_session, target_wxid, cursor=0, limit=1, direction="before", ) messages = result.get("messages", []) if result else [] if not messages: return (0, 0) msg = messages[0] return ( int(msg.get("create_time", 0)), int(msg.get("local_id", 0)), ) except Exception as exc: logger.warning( "capture_forward_baseline: 查询目标会话基线异常 (target=%s): %s", target_display_name, exc, ) return (0, 0) async def post_verify_forward_message( self, db_reader: Any, target_display_name: str, sleep_sec: float = 2.0, timeout_sec: float = 3.0, baseline_create_time: int = 0, baseline_local_id: int = 0, ) -> bool: """校验转发是否生效(Task 11)。 sleep 2s 后查目标会话消息,成功判定 type=49(转发/链接消息)。目标 wxid 通过 get_contacts(keyword=target_display_name) 解析,解析失败返回 False。 P0 修复:新增 baseline_create_time / baseline_local_id 参数。传入基线时 用 direction="after" 只查 UI 操作后的新消息,避免历史 type=49 消息误报; 未传基线(默认 0,0)时回退原行为(查最近 5 条),并打 warning 说明可能 误报,保持向后兼容。 Args: db_reader: DbReader 实例(可为 None,此时跳过返回 True) target_display_name: 转发目标联系人的显示名 sleep_sec: 首次查询前等待秒数 timeout_sec: 轮询超时秒数 baseline_create_time: UI 操作前目标会话最新消息的 create_time; >0 时启用基线模式(只查基线之后的新消息) baseline_local_id: 配合 baseline_create_time 的 tie-breaker Returns: True 表示目标会话出现 type=49 转发消息;False 表示未检测到 """ if db_reader is None: logger.warning( "post_verify_forward_message: db_reader 为 None,跳过校验 (target=%s)", target_display_name, ) return True # 解析目标 wxid(复用 helper,避免重复逻辑) target_wxid = await self._resolve_wxid_by_display_name( db_reader, target_display_name ) if not target_wxid: logger.warning( "post_verify_forward_message: 无法解析目标 wxid (target=%s),返回 False", target_display_name, ) return False use_baseline = baseline_create_time > 0 if not use_baseline: logger.warning( "post_verify_forward_message: 未传基线,回退查最近 5 条消息," "历史 type=49 消息可能误报 (target=%s)", target_display_name, ) await asyncio.sleep(sleep_sec) deadline = time.monotonic() + timeout_sec while time.monotonic() < deadline: try: if use_baseline: # 基线模式:只查基线之后的新消息,避免历史消息误报 result = await asyncio.to_thread( db_reader.get_messages_by_session, target_wxid, cursor=baseline_create_time, limit=5, direction="after", cursor_local_id=baseline_local_id, ) else: # 向后兼容:查最近 5 条消息(有误报风险) result = await asyncio.to_thread( db_reader.get_messages_by_session, target_wxid, cursor=0, limit=5, direction="before", ) for msg in result.get("messages", []): if int(msg.get("type", 0)) == 49: logger.info( "post_verify_forward_message: 检测到 type=49 转发消息 (target=%s, baseline=%s)", target_display_name, use_baseline, ) return True except Exception as exc: logger.warning( "post_verify_forward_message: DB 查询异常 (target=%s): %s", target_display_name, exc, ) await asyncio.sleep(1.0) logger.warning( "post_verify_forward_message: 超时 %.1fs 未检测到转发消息 (target=%s)", timeout_sec, target_display_name, ) return False