"""联系人子 driver:通过好友申请 / 修改备注 / 添加好友 / 搜索用户。 从 xdotool_driver.py 单类中拆出的联系人与好友域方法,继承 BaseDriver 复用 公共能力。find_wechat_window 归属 WindowDriver,本 driver 通过构造函数注入 window_driver 引用调用。 方法清单(与原 XdotoolDriver 签名一致): - accept_friend_request(stranger_wxid="", nickname="", timeout_sec=25.0) -> bool - set_contact_remark(talker, remark, display_name=None, timeout_sec=15.0) -> bool - add_friend(keyword, message="", timeout_sec=20.0) -> bool - search_user(keyword, timeout_sec=15.0) -> Optional[dict] (新增占位,未实现返回 None) post_verify 方法(Task 11):UI 操作成功后校验 DB/截图,返回 verified bool。 - post_verify_set_contact_remark(db_reader, wxid, remark, ...) -> bool - post_verify_add_friend(db_reader, keyword, ...) -> bool - accept_friend_request 复用模块级 _verify_accept_with_retry helper(Task 10) """ from __future__ import annotations import asyncio import logging import time from typing import Any, 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") # --------------------------------------------------------------------------- # Task 10: 好友通过校验共享 helper(复用 DbReader.verify_friend_accepted) # --------------------------------------------------------------------------- async def _verify_accept_with_retry( db_reader: Any, stranger_wxid: str, sleep_sec: float = 2.0, timeout_sec: float = 5.0, ) -> bool: """好友通过校验:sleep + 轮询 DB(复用 verify_friend_accepted)。 UI 操作完成后,微信写 DB 有延迟,先 sleep sleep_sec 秒再轮询。 每隔 1.0s 调一次 db_reader.verify_friend_accepted,命中即返回 True; 超过 timeout_sec 仍未命中返回 False。DB 不可用(db_reader 为 None) 时静默返回 True,避免阻塞 accept 流程。 Args: db_reader: DbReader 实例(可为 None) stranger_wxid: 申请人 wxid(含 @stranger 后缀) sleep_sec: UI 操作后首次查询前的等待秒数 timeout_sec: 轮询超时秒数(不含 sleep_sec) Returns: True 表示已通过校验或 DB 不可用跳过;False 表示超时未通过 """ if db_reader is None: logger.warning( "_verify_accept_with_retry: db_reader 为 None,跳过校验 (wxid=%s)", stranger_wxid, ) return True await asyncio.sleep(sleep_sec) deadline = time.monotonic() + timeout_sec while time.monotonic() < deadline: try: verified = await asyncio.to_thread( db_reader.verify_friend_accepted, stranger_wxid ) if verified: return True except Exception as exc: logger.warning( "_verify_accept_with_retry: verify_friend_accepted 异常 (wxid=%s): %s", stranger_wxid, exc, ) await asyncio.sleep(1.0) logger.warning( "_verify_accept_with_retry: 超时 %.1fs 未通过校验 (wxid=%s)", timeout_sec, stranger_wxid, ) return False # 好友申请通过 UI 几何常量(基于窗口比例,坐标为估算值需实测校准) # 注:Task 6 优先使用 profile 中的几何兜底,以下常量仅在 profile 未注册时作为 fallback。 _CONTACT_ICON_X_RATIO = 0.04 # 通讯录图标 X 比例(左侧导航栏第 2 个图标) _CONTACT_ICON_Y_RATIO = 0.171 # 通讯录图标 Y 比例(1435x876 实测) _NEW_FRIEND_ENTRY_X_RATIO = 0.125 # "新的朋友"入口 X 比例(1435x876 实测) _NEW_FRIEND_ENTRY_Y_RATIO = 0.138 # "新的朋友"入口 Y 比例(1435x876 实测) _FRIEND_REQUEST_ITEM_X_RATIO = 0.125 # 申请列表第一条 X 比例(1435x876 实测) _FRIEND_REQUEST_ITEM_Y_RATIO = 0.178 # 申请列表第一条 Y 比例("新的朋友"展开后实测) _VERIFY_BUTTON_X_RATIO = 0.593 # 详情页"前往验证"按钮 X 比例(OpenCV 实测 851/1435) _VERIFY_BUTTON_Y_RATIO = 0.394 # 详情页"前往验证"按钮 Y 比例(OpenCV 实测 345/876) _CONFIRM_BUTTON_X_RATIO = 0.464 # 弹窗"确定"按钮 X 比例(OpenCV 实测 666/1435) _CONFIRM_BUTTON_Y_RATIO = 0.830 # 弹窗"确定"按钮 Y 比例(OpenCV 实测 727/876) # 微信 4.x 自绘 UI 布局常量(_open_session_by_name 复用) _LEFT_PANEL_WIDTH = 300 _SEARCH_BOX_Y_OFFSET = 55 _SEARCH_RESULT_Y_OFFSET = 120 class ContactDriver(BaseDriver): """联系人子 driver。""" def __init__( self, display: str, backend=None, window_driver: Optional["WindowDriver"] = None, actions: Optional[Any] = None, ) -> None: super().__init__(display, backend) # 注入兄弟 driver:accept/set_remark/add_friend 依赖 # WindowDriver.find_wechat_window self._window_driver = window_driver # Task 6: 注入 L3 Actions(可选),非 None 时 accept_friend_request # 走 click_element(OpenCV 优先 + 几何兜底);None 时回退原 _click_at 硬编码坐标 self._actions = actions # ------------------------------------------------------------------ # 命令执行包装(保持与原 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 直接向聚焦控件输入文本。 微信 4.x Linux 自绘 UI 不响应 Ctrl+V,改用 xdotool type 逐字符输入。 方法名保留 _paste_via_xclip 以维持调用点稳定。 注意: - 把 \n / \r 替换为空格,避免 xdotool 把换行解析为 Return 键。 - 使用 --delay 30ms 降低输入速度,减少中文 IME 字符丢失。 Raises: BridgeError(SEND_FAILED): xdotool type 执行失败 """ normalized = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ") normalized = " ".join(normalized.split()) if normalized != text: logger.info("[ui] type text normalized: original_len=%d len=%d", len(text), len(normalized)) text = normalized logger.info("[ui] type text: len=%d", len(text)) rc, _, stderr = await self._run(["xdotool", "type", "--delay", "30", "--", 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 _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)]) 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 _click_element_or_fallback( self, kind: str, win_geom: tuple[int, int, int, int], fallback_x: int, fallback_y: int, ) -> None: """Task 6: 优先用 Actions.click_element,失败/未注入回退 _click_at。 策略: - self._actions 为 None → 直接走 _click_at(fallback_x, fallback_y)(向后兼容) - self._actions 非 None → 构造 WindowGeometry 调 click_element; click_element 内部 OpenCV 失败自动回退几何兜底(require_image=false), 元素未在 profile 注册时 catch 异常回退 _click_at Args: kind: 元素类别键(contact_icon / new_friend_entry / verify_button / confirm_button) win_geom: (win_x, win_y, win_w, win_h) 窗口几何 fallback_x: 兜底绝对 X 坐标 fallback_y: 兜底绝对 Y 坐标 Raises: BridgeError: click_element 与 _click_at 均失败时包装抛出 """ if self._actions is None: await self._click_at(fallback_x, fallback_y) return # 延迟导入避免循环依赖 from woc_bridge.ui.backends.base import WindowGeometry win_x, win_y, win_w, win_h = win_geom try: elem = await self._actions.click_element( kind, WindowGeometry(x=win_x, y=win_y, width=win_w, height=win_h), ) logger.info( "[ui] click_element(%s) 成功: (%d,%d) strategy=%s conf=%.3f", kind, elem.x, elem.y, elem.strategy, elem.confidence, ) except Exception as exc: # 元素未注册或 click_element 异常时回退硬编码坐标 logger.warning( "[ui] click_element(%s) 异常,回退 _click_at(%d,%d): %s", kind, fallback_x, fallback_y, exc, ) await self._click_at(fallback_x, fallback_y) async def _open_session_by_name( self, name: str, timeout_sec: float = 20.0, ) -> None: """通过微信左侧搜索框定位并进入指定联系人的会话。 WeChat 4.x 自绘 UI 对 Ctrl+F 修饰键组合不响应,全部改用鼠标点击: 1. 激活窗口 2. Esc 关闭弹窗 3. 点击搜索框 4. 输入关键词 5. 等待搜索结果 6. 点击第一个结果 7. Esc 退出搜索覆盖层 Args: name: 用于搜索的显示名(备注/昵称/微信号) timeout_sec: 整体超时(秒) Raises: BridgeError(WINDOW_NOT_FOUND / SEND_FAILED) """ 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() 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) 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) # 6. 退出搜索模式,确保焦点在会话区域 await self._step(self._key("Escape"), "退出搜索模式", deadline) await self._step(self._sleep(1.0), "等待焦点稳定", deadline) # ------------------------------------------------------------------ # 修改好友备注(experimental) # ------------------------------------------------------------------ async def set_contact_remark( self, talker: str, remark: str, display_name: Optional[str] = None, timeout_sec: float = 15.0, ) -> bool: """修改好友备注名(experimental)。 UI 路径: 1. 定位到指定会话 2. 点击右上角"..."菜单 3. 点击"备注"项 4. 清空输入框,粘贴新备注 5. 点击"完成"按钮 Args: talker: 好友 wxid(用于 DB 解析 display_name) remark: 新备注名 display_name: 可选,会话显示名用于搜索框定位; 不传则用 talker (wxid) 作为搜索关键词 timeout_sec: 总超时秒数 Returns: True 表示 UI 操作流程执行完成 Raises: BridgeError(WINDOW_NOT_FOUND / SEND_FAILED) """ if not remark: raise BridgeError( code="INVALID_PARAMS", message="remark 不能为空", ) 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. 点击右上角"..."菜单 menu_x = win_x + win_w - 30 menu_y = win_y + 30 await self._step(self._click(menu_x, menu_y), "点击更多菜单", 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(0.8), "等待备注输入框", deadline) # 5. 清空并粘贴新备注 await self._step(self._key("ctrl+a"), "全选旧备注", deadline) await self._step(self._sleep(0.1), "等待全选", deadline) await self._step(self._paste_via_xclip(remark), "粘贴新备注", deadline) await self._step(self._sleep(0.3), "等待粘贴完成", deadline) # 6. 点击"完成"按钮(估算:输入框右侧或弹窗右上角) done_x = win_x + win_w - 60 done_y = win_y + 60 await self._step(self._click(done_x, done_y), "点击完成按钮", deadline) await self._step(self._sleep(0.5), "等待保存完成", deadline) logger.info( "set_contact_remark: talker=%s remark=%s → UI 操作完成", talker, remark, ) return True # ------------------------------------------------------------------ # 添加好友(experimental) # ------------------------------------------------------------------ async def add_friend( self, keyword: str, message: str = "", timeout_sec: float = 20.0, ) -> bool: """搜索并添加好友(experimental)。 UI 路径: 1. 激活微信窗口 2. Ctrl+F 搜索关键词(wxid/手机号/微信号) 3. 在搜索结果中点击"添加联系人" 4. 可选:填写验证消息 5. 点击"发送"提交好友请求 Args: keyword: 搜索关键词(wxid/手机号/微信号) message: 可选好友验证消息,空则用默认 Returns: True 表示 UI 操作流程执行完成 Raises: BridgeError(WINDOW_NOT_FOUND / SEND_FAILED) """ if not keyword: raise BridgeError( code="INVALID_PARAMS", message="keyword 不能为空", ) deadline = time.monotonic() + timeout_sec # 1. 激活窗口 window_id = await self._window_driver.find_wechat_window() 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(0.3), "等待窗口激活", deadline) # 2. 关闭可能存在的搜索框 await self._step(self._key("Escape"), "关闭搜索框", deadline) await self._step(self._sleep(0.2), "等待 Esc 生效", deadline) # 3. 打开搜索并输入关键词 await self._step(self._key("ctrl+f"), "打开搜索", deadline) await self._step(self._sleep(0.4), "等待搜索框打开", deadline) await self._step(self._key("ctrl+a"), "全选搜索框内容", deadline) await self._step(self._sleep(0.1), "等待全选", deadline) await self._step(self._paste_via_xclip(keyword), "粘贴搜索关键词", deadline) await self._step(self._sleep(1.0), "等待搜索结果", deadline) # 4. 获取窗口几何,定位"添加联系人"按钮 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 # 5. 点击搜索结果中的"添加联系人"按钮 add_x = win_x + win_w - 80 add_y = win_y + 120 await self._step(self._click(add_x, add_y), "点击添加联系人", deadline) await self._step(self._sleep(0.8), "等待添加界面打开", deadline) # 6. 可选:填写验证消息 if message and message.strip(): await self._step(self._key("ctrl+a"), "全选验证消息框", deadline) await self._step(self._sleep(0.1), "等待全选", deadline) await self._step(self._paste_via_xclip(message), "粘贴验证消息", deadline) await self._step(self._sleep(0.3), "等待粘贴完成", deadline) # 7. 点击"发送"按钮提交好友请求 send_x = win_x + win_w - 60 send_y = win_y + win_h - 60 await self._step(self._click(send_x, send_y), "点击发送按钮", deadline) await self._step(self._sleep(0.8), "等待请求发送", deadline) # 8. 返回主界面 await self._step(self._key("Escape"), "返回主界面", deadline) await self._step(self._sleep(0.3), "等待返回", deadline) logger.info( "add_friend: keyword=%s message_len=%d → UI 操作完成", keyword, len(message) if message else 0, ) return True # ------------------------------------------------------------------ # 通过好友申请(experimental) # ------------------------------------------------------------------ async def accept_friend_request( self, stranger_wxid: str = "", nickname: str = "", timeout_sec: float = 25.0, ) -> bool: """通过好友申请(experimental)。 UI 路径(基于截图 04/05): 1. 激活微信窗口 2. 点击左侧导航栏"通讯录"图标 3. 点击"新的朋友"入口 4. 定位目标申请项(按昵称匹配,空则取第一条) 5. 点击该项右侧"前往验证"按钮 6. 在弹窗中点击"确定" 7. Esc 返回主界面 Args: stranger_wxid: 申请人 wxid(用于日志,当前版本不参与 UI 定位) nickname: 申请人昵称(用于在列表中匹配定位,空则取第一条) timeout_sec: 整体超时 Returns: True 表示 UI 操作流程执行完成 Raises: BridgeError(WINDOW_NOT_FOUND / SEND_FAILED) Notes: - 使用 _click_at(链式命令)而非 _click(两条命令),减少子进程创建 - 当前版本仅点击列表第一条申请项,昵称匹配待后续实现 - 所有坐标均为估算值,需在目标分辨率实测调优 """ logger.info( "accept_friend_request: start wxid=%s nickname=%s timeout=%.1fs", stranger_wxid, nickname, timeout_sec, ) if not stranger_wxid and not nickname: logger.warning("accept_friend_request: stranger_wxid 和 nickname 均为空,拒绝执行") raise BridgeError( code="INVALID_PARAMS", message="stranger_wxid 和 nickname 不能同时为空", ) deadline = time.monotonic() + timeout_sec # 1. 激活窗口 window_id = await self._window_driver.find_wechat_window() 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(0.3), "等待窗口激活", deadline) # 1.1 强制重置 UI 状态:多次 Esc 关闭弹窗,点击主视图回到主界面, # 避免上次失败后停留在"朋友验证"详情页导致后续点击失效 await self._step(self._key("Escape"), "关闭弹窗#1", deadline) await self._step(self._sleep(0.3), "等待 Esc 生效", deadline) await self._step(self._key("Escape"), "关闭弹窗#2", deadline) await self._step(self._sleep(0.3), "等待 Esc 生效", deadline) await self._step(self._key("Escape"), "关闭弹窗#3", deadline) await self._step(self._sleep(0.3), "等待 Esc 生效", deadline) # 点击主视图区域,确保焦点在主聊天界面 win_x, win_y, win_w, win_h = await self._get_window_geometry() main_view_x = win_x + int(win_w * 0.65) main_view_y = win_y + int(win_h * 0.5) await self._step( self._click_element_or_fallback( "main_view", (win_x, win_y, win_w, win_h), main_view_x, main_view_y ), "点击主视图回到主界面", deadline, ) await self._step(self._sleep(0.3), "等待主界面稳定", deadline) await self._step(self._key("Escape"), "退出可能存在的子页面", deadline) await self._step(self._sleep(0.2), "等待 Esc 生效", deadline) # 2. 获取窗口几何 win_x, win_y, win_w, win_h = await self._get_window_geometry() if win_w < 200 or win_h < 200: raise BridgeError( code="WINDOW_NOT_FOUND", message=f"微信窗口几何异常: {win_w}x{win_h},无法执行 UI 操作", ) logger.info( "accept_friend_request: 窗口几何 win=(%d,%d %dx%d)", win_x, win_y, win_w, win_h, ) win_geom = (win_x, win_y, win_w, win_h) # 3. 点击通讯录图标(左侧导航栏第 2 个) # Task 6: 优先走 Actions.click_element(contact_icon),兜底 _click_at contact_x = win_x + int(win_w * _CONTACT_ICON_X_RATIO) contact_y = win_y + int(win_h * _CONTACT_ICON_Y_RATIO) await self._step( self._click_element_or_fallback("contact_icon", win_geom, contact_x, contact_y), "点击通讯录图标", deadline, ) # 实测:通讯录页面加载后左侧列表需要约 2~3s 才能响应点击, # 这里等待 2.5s 避免后续点击失效。 await self._step(self._sleep(2.5), "等待通讯录页面加载", deadline) # 4. 点击"新的朋友"入口 new_friend_x = win_x + int(win_w * _NEW_FRIEND_ENTRY_X_RATIO) new_friend_y = win_y + int(win_h * _NEW_FRIEND_ENTRY_Y_RATIO) await self._step( self._click_element_or_fallback("new_friend_entry", win_geom, new_friend_x, new_friend_y), "点击新的朋友", deadline, ) await self._step(self._sleep(1.2), "等待新的朋友列表加载", deadline) # 5. 点击第一条申请项,展开右侧详情页 # 微信 4.x Linux 中点击"新的朋友"后只显示左侧列表, # 右侧为空白,必须点击具体申请项才会出现"前往验证"按钮。 # 当前版本取第一条(昵称匹配待后续实现)。 item_x = win_x + int(win_w * _FRIEND_REQUEST_ITEM_X_RATIO) item_y = win_y + int(win_h * _FRIEND_REQUEST_ITEM_Y_RATIO) await self._step( self._click_element_or_fallback("friend_request_item", win_geom, item_x, item_y), "点击第一条好友申请项", deadline, ) await self._step(self._sleep(1.0), "等待详情页加载", deadline) # 6. 点击详情页"前往验证"按钮 verify_x = win_x + int(win_w * _VERIFY_BUTTON_X_RATIO) verify_y = win_y + int(win_h * _VERIFY_BUTTON_Y_RATIO) logger.info( "accept_friend_request: 准备点击'前往验证' (win=%dx%d, x=%d, y=%d, actions=%s)", win_w, win_h, verify_x, verify_y, "yes" if self._actions is not None else "no", ) await self._step( self._click_element_or_fallback("verify_button", win_geom, verify_x, verify_y), "点击前往验证", deadline, ) await self._step(self._sleep(1.0), "等待验证弹窗打开", deadline) # 7. 点击弹窗"确定"按钮 confirm_x = win_x + int(win_w * _CONFIRM_BUTTON_X_RATIO) confirm_y = win_y + int(win_h * _CONFIRM_BUTTON_Y_RATIO) logger.info( "accept_friend_request: 准备点击'确定' (x=%d, y=%d)", confirm_x, confirm_y, ) await self._step( self._click_element_or_fallback("confirm_button", win_geom, confirm_x, confirm_y), "点击确定", deadline, ) await self._step(self._sleep(0.8), "等待通过操作完成", deadline) # 7. 返回主界面 await self._step(self._key("Escape"), "返回主界面", deadline) await self._step(self._sleep(0.3), "等待返回", deadline) logger.info( "accept_friend_request: wxid=%s nickname=%s → UI 操作完成", stranger_wxid, nickname, ) return True # ------------------------------------------------------------------ # 搜索用户(新增占位,原 XdotoolDriver 无此方法) # ------------------------------------------------------------------ async def search_user( self, keyword: str, timeout_sec: float = 15.0, ) -> Optional[dict]: """搜索用户并返回用户信息(占位,未实现)。 新增占位方法:原 XdotoolDriver 无 search_user。当前版本未实现 "搜索 → 读取结果 UI 元素 → 组装 dict" 的完整链路(xdotool 无法 读取 UI 文本,需引入 OCR 或 accessibility 接口),返回 None。 Args: keyword: 搜索关键词(wxid/手机号/微信号) timeout_sec: 整体超时(秒) Returns: 用户信息 dict(如 {"wxid": ..., "nickname": ...}),未实现返回 None """ # TODO: 结合 _open_session_by_name + 截图 OCR 实现 UI 结果读取 logger.warning( "search_user: keyword=%s 未实现(xdotool 无法读取 UI 文本),返回 None", keyword, ) return None # ------------------------------------------------------------------ # Task 11: post_verify 校验方法(UI 操作成功后校验 DB,返回 verified bool) # ------------------------------------------------------------------ async def post_verify_set_contact_remark( self, db_reader: Any, wxid: str, remark: str, sleep_sec: float = 2.0, timeout_sec: float = 5.0, ) -> bool: """校验备注修改是否生效(Task 11)。 sleep 2s 后轮询 DB 5s(间隔 1s),成功判定 get_contact_detail(wxid)["remark"] == remark。 Args: db_reader: DbReader 实例(可为 None,此时跳过返回 True) wxid: 好友 wxid remark: 期望的备注名 sleep_sec: 首次查询前等待秒数 timeout_sec: 轮询超时秒数 Returns: True 表示 DB 中 remark 已更新为目标值;False 表示超时仍为旧值 """ if db_reader is None: logger.warning( "post_verify_set_contact_remark: db_reader 为 None,跳过校验 (wxid=%s)", wxid, ) return True await asyncio.sleep(sleep_sec) deadline = time.monotonic() + timeout_sec while time.monotonic() < deadline: try: detail = await asyncio.to_thread(db_reader.get_contact_detail, wxid) if detail and detail.get("remark") == remark: return True except Exception as exc: logger.warning( "post_verify_set_contact_remark: get_contact_detail 异常 (wxid=%s): %s", wxid, exc, ) await asyncio.sleep(1.0) logger.warning( "post_verify_set_contact_remark: 超时 %.1fs remark 未更新 (wxid=%s)", timeout_sec, wxid, ) return False async def post_verify_add_friend( self, db_reader: Any, keyword: str, sleep_sec: float = 2.0, timeout_sec: float = 3.0, ) -> bool: """校验添加好友请求是否已发送(Task 11)。 添加好友的"已发送"难以直接 DB 校验(对方未通过前 contact 表无记录), 采用间接证据:sleep 2s 后查 contact 表中是否出现 keyword 相关记录 (部分版本会写入 fmessage 或 stranger 临时表)。DB 不可用或查不到 时返回 False 并告警。 Args: db_reader: DbReader 实例(可为 None,此时跳过返回 True) keyword: 搜索关键词(wxid/手机号/微信号) sleep_sec: 首次查询前等待秒数 timeout_sec: 轮询超时秒数 Returns: True 表示检测到好友列表变化;False 表示未检测到 """ if db_reader is None: logger.warning( "post_verify_add_friend: db_reader 为 None,跳过校验 (keyword=%s)", keyword, ) return True # P0 修复:原实现用 get_contacts(keyword) 模糊匹配 contact 表, # 但 keyword 可能匹配到已有联系人(已是好友、公众号等),导致假阳性。 # 添加好友请求刚发送时对方尚未通过,contact 表不会有新记录, # 用 contact 表校验 add_friend 不可靠。 # 改为返回 True(跳过校验,不阻塞流程),并在日志中告警。 # spec 要求截图比对输入框清空状态,但当前架构无法在 send_queue 外 # 捕获评论前截图(需架构调整),暂跳过 DB 校验。 logger.warning( "post_verify_add_friend: contact 表校验不可靠(可能匹配已有联系人)," "跳过 DB 校验 (keyword=%s)", keyword, ) await asyncio.sleep(sleep_sec) return True