WechatOnCloud/bridge/woc_bridge/ui/drivers/message.py
Kris 3effe1338c feat: 新增纯坐标模式开关与文本输入优化
1. 新增WOC_DISABLE_OPENCV环境变量支持纯坐标定位模式
2. 优化xdotool文本输入:替换换行符为空格、添加30ms输入延迟
3. 新增输入框清空逻辑,避免内容残留
2026-07-18 05:23:06 +08:00

1042 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""消息子 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.backends.base import WindowGeometry
from woc_bridge.ui.drivers.base import BaseDriver
if TYPE_CHECKING:
from woc_bridge.ui.drivers.window import WindowDriver
logger = logging.getLogger("woc-bridge")
# 微信窗口标题常量(与 flow 路径保持一致)
_WECHAT_WINDOW_TITLE = "微信"
# ---------------------------------------------------------------------------
# 分层超时与布局常量(与 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,
actions: Optional[Any] = None,
) -> None:
super().__init__(display, backend)
# 注入兄弟 driver_open_session_by_name / revoke / forward 依赖
# WindowDriver.find_wechat_window
self._window_driver = window_driver
# 注入 L3 Actions可选非 None 时 _open_session_by_name / 输入框 /
# 发送按钮走 LocatorRegistry + profile 比例坐标None 时回退原硬编码坐标。
self._actions = actions
# 调试截图:环境变量控制开关(与原 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_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 直接向聚焦控件输入文本。
历史背景:原实现用 xclip 写剪贴板 + Ctrl+V 粘贴,但微信 4.x Linux
自绘 UI 不响应 X11 core keyboard 的修饰键组合Ctrl+V只响应单
字符键盘事件。xev 验证 KeyPress 事件确实到达窗口但微信不处理,
而 xdotool type逐字符 XSendEvent能正常输入中英文。因此改用
xdotool type 直接打字。方法名保留 _paste_via_xclip 以维持调用点稳定。
注意:
- 把 \n / \r 替换为空格,避免 xdotool 把换行解析为 Return 键触发发送。
- 使用 --delay 30ms 降低输入速度,减少中文 IME 字符丢失。
Raises:
BridgeError(SEND_FAILED): xdotool type 执行失败
"""
# 规范化:去除换行,避免 Return 误触发
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 _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()
# ------------------------------------------------------------------
# 输入框 / 发送按钮定位(优先走 Actions + profile 比例坐标)
# ------------------------------------------------------------------
async def _get_window_geometry_backend(self) -> WindowGeometry:
"""获取窗口几何,返回 backend 统一的 WindowGeometry。
优先委托 XdotoolBackend无 backend 时自建 WindowGeometry。
"""
if self._backend is not None and hasattr(
self._backend, "get_window_geometry"
):
return await self._backend.get_window_geometry(_WECHAT_WINDOW_TITLE)
win_x, win_y, win_w, win_h = await self._get_window_geometry()
return WindowGeometry(x=win_x, y=win_y, width=win_w, height=win_h)
async def _click_input_box(self) -> None:
"""鼠标点击聊天输入框,强制焦点落到输入框。"""
if self._actions is not None:
geom = await self._get_window_geometry_backend()
await self._actions.click_element("input_box", geom)
await asyncio.sleep(0.2)
return
# legacy fallback固定像素坐标
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 键。"""
if self._actions is not None:
geom = await self._get_window_geometry_backend()
await self._actions.click_element("send_button", geom)
return
# legacy fallback固定像素坐标
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:
"""通过微信左侧搜索框定位并进入指定联系人的会话。
优先走 Actions + LocatorRegistry + YAML profile 比例坐标;
未注入 Actions 时回退原硬编码坐标逻辑。
新流程:
1. 激活窗口并获取几何
2. 点击左侧"会话管理"图标重置状态(回到会话列表首页)
3. 点击左侧顶部搜索框
4. Ctrl+A + BackSpace 清空旧内容
5. 输入 display_name
6. 等待并点击第一个搜索结果
7. 验证 input_box 出现(确保真正进入会话)
8. 按 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 不能为空,无法定位会话",
)
if self._actions is None:
return await self._open_session_by_name_legacy(name, timeout_sec)
deadline = time.monotonic() + timeout_sec
backend = self._actions.backend
# 1. 激活窗口并获取几何
geom = await self._get_window_geometry_backend()
await self._step(backend.activate_window(_WECHAT_WINDOW_TITLE), "激活窗口", deadline)
# 2. 重置状态:点击左侧"会话管理"图标,强制回到会话列表首页
await self._step(
self._actions.click_element("session_manager_icon", geom),
"点击会话管理图标重置状态",
deadline,
)
await self._step(backend.key_press("Escape"), "关闭可能打开的菜单", deadline)
await self._step(asyncio.sleep(0.3), "等待重置生效", deadline)
# 3. 点击搜索框
await self._step(self._actions.click_element("search_box", geom), "点击搜索框", deadline)
await self._step(asyncio.sleep(0.2), "等待搜索框聚焦", deadline)
# 4. 清空旧内容Ctrl+A 全选 + BackSpace 删除
await self._step(backend.key_press("ctrl+a"), "全选搜索框旧内容", deadline)
await self._step(backend.key_press("BackSpace"), "删除搜索框旧内容", deadline)
await self._step(asyncio.sleep(0.2), "等待清空", deadline)
# 5. 输入搜索关键词
await self._step(backend.type_text(name), "输入搜索关键词", deadline)
await self._step(asyncio.sleep(0.5), "等待搜索结果渲染", deadline)
# 6. 点击第一个搜索结果进入会话
await self._step(
self._actions.click_element("search_result_first", geom),
"点击第一个搜索结果",
deadline,
)
await self._step(asyncio.sleep(0.5), "等待会话打开", deadline)
await self._debug_screenshot("after_open_session")
# 7. 验证 input_box 出现(确保真正进入目标会话)
input_appeared = False
verify_deadline = time.monotonic() + 2.0
while time.monotonic() < verify_deadline:
try:
elem = await self._actions.wait_for(
"input_box", geom, timeout=0.5, interval=0.1
)
if elem is not None:
input_appeared = True
break
except Exception:
pass
await asyncio.sleep(0.2)
if not input_appeared:
raise BridgeError(
code="SEND_FAILED",
message="点击搜索结果后未进入目标会话(未找到输入框)",
)
# 8. 退出搜索模式,确保焦点在会话区域
await self._step(backend.key_press("Escape"), "退出搜索模式", deadline)
await self._step(asyncio.sleep(0.2), "等待焦点稳定", deadline)
async def _open_session_by_name_legacy(
self,
name: str,
timeout_sec: float = 20.0,
) -> None:
"""原硬编码坐标实现(保留兼容)。"""
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_<unix秒>_<随机>
"""
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(0.3)
# 优先用鼠标点击右下角发送按钮,规避 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(0.3)
await self._debug_screenshot("after_send")
# 3. 按 Esc 退出当前聊天界面,回到会话列表,避免影响下一次发送
await self._key("Escape")
await self._sleep(0.3)
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_<unix秒>_<随机>
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:
"""通过显示名解析联系人 wxidpost_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