1. 新增WOC_DISABLE_OPENCV环境变量支持纯坐标定位模式 2. 优化xdotool文本输入:替换换行符为空格、添加30ms输入延迟 3. 新增输入框清空逻辑,避免内容残留
860 lines
36 KiB
Python
860 lines
36 KiB
Python
"""联系人子 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] <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 以维持调用点稳定。
|
||
|
||
注意:
|
||
- 把 \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
|