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

1042 lines
45 KiB
Python
Raw Normal View History

"""消息子 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