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