WechatOnCloud/bridge/woc_bridge/ui/drivers/contact.py

851 lines
36 KiB
Python
Raw Normal View History

"""联系人子 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 11UI 操作成功后校验 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 helperTask 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 仍未命中返回 FalseDB 不可用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)
# 注入兄弟 driveraccept/set_remark/add_friend 依赖
# WindowDriver.find_wechat_window
self._window_driver = window_driver
# Task 6: 注入 L3 Actions可选非 None 时 accept_friend_request
# 走 click_elementOpenCV 优先 + 几何兜底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_managedTimeoutError 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] <key>。"""
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 以维持调用点稳定
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 _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