修复QQ抓联系人列表+微信自动回复错发给QQ+重复处理\n\n问题1: QQ/TIM抓会话列表而非消息\n 根因: _click_message_area单次点击(width*0.88)落在联系人/群列表\n 而非消息显示区,导致Ctrl+A复制联系人名而非消息内容\n 修复: _focus_message_area两次SendMessage点击\n 先点会话列表(左15%)选对话→再点消息区(中右60%)聚焦消息\n + Esc前导键退出搜索模式\n\n问题2: 微信自动回复发给QQ\n 根因: _attach_set_foreground静默失败不返回结果\n SendKeys输入到当前前台窗口(QQ)\n 修复: _ensure_foreground使用GetForegroundWindow验证\n 确认目标窗口真正在前台,失败则return False放弃发送\n + 处理托盘/最小化窗口(ShowWindow SW_SHOWNORMAL)\n\n问题3: 重复处理同1条消息\n 根因: QQ每次读到不同联系人名=不同新消息=反复触发\n 修复: ①聚焦消息区后读到稳定消息内容 ②_sending_event锁\n 自动回复发送期间阻止监控轮询,避免焦点被抢\n\n其他: _ensure_hwnd检测QQ NT vs TIM变体\n 用于选择UIA优先或剪贴板优先策略
This commit is contained in:
+104
-33
@@ -49,6 +49,11 @@ class MessageMonitor:
|
||||
# 剪贴板原始文本(用于自动回复提取发送者)
|
||||
self._raw_clipboard: dict = {}
|
||||
|
||||
# 发送中事件锁(防止自动回复时监控轮询干扰)
|
||||
self._sending_event = threading.Event()
|
||||
# 应用变体:qq→"qq_nt"或"tim"
|
||||
self._app_variant: dict = {}
|
||||
|
||||
def set_poll_interval(self, seconds: float):
|
||||
self._poll_interval = max(0.5, seconds)
|
||||
|
||||
@@ -95,12 +100,12 @@ class MessageMonitor:
|
||||
heartbeat = 0
|
||||
while self._running:
|
||||
try:
|
||||
if self._wechat_enabled:
|
||||
if self._wechat_enabled and not self._sending_event.is_set():
|
||||
self._poll("wechat")
|
||||
if self._qq_enabled:
|
||||
# QQ 降频:每 10 周期 poll 一次(~20s),减少抢窗口
|
||||
# QQ 降频:每 10 周期 poll 一次(~20s)
|
||||
self._qq_skip_counter += 1
|
||||
if self._qq_skip_counter >= 10:
|
||||
if self._qq_skip_counter >= 10 and not self._sending_event.is_set():
|
||||
self._qq_skip_counter = 0
|
||||
self._poll("qq")
|
||||
except Exception as e:
|
||||
@@ -147,11 +152,12 @@ class MessageMonitor:
|
||||
if rect.width() > 200 and rect.height() > 100:
|
||||
h = w.NativeWindowHandle
|
||||
self._cached_hwnd[platform] = h
|
||||
self._app_variant[platform] = "qq_nt" if name == "QQ" else "tim"
|
||||
logger.info(f"[{platform}] 窗口已定位(名称): {name}")
|
||||
return h
|
||||
except Exception:
|
||||
pass
|
||||
# QQ NT → TXGuiFoundation 类名
|
||||
# QQ NT/TIM → TXGuiFoundation 类名
|
||||
try:
|
||||
root = auto.GetRootControl()
|
||||
for child in root.GetChildren():
|
||||
@@ -160,6 +166,9 @@ class MessageMonitor:
|
||||
rect = child.BoundingRectangle
|
||||
if rect.width() > 200 and rect.height() > 100:
|
||||
h = child.NativeWindowHandle
|
||||
# 判断变体:名称含"TIM"则为tim,否则qq_nt
|
||||
cname = str(child.Name or "")
|
||||
self._app_variant[platform] = "tim" if "TIM" in cname.upper() else "qq_nt"
|
||||
self._cached_hwnd[platform] = h
|
||||
logger.info(f"[{platform}] 窗口已定位(TXGuiFoundation): {child.Name}")
|
||||
return h
|
||||
@@ -225,35 +234,72 @@ class MessageMonitor:
|
||||
except Exception as e:
|
||||
logger.debug(f"[{platform}] 滚动到底失败: {e}")
|
||||
|
||||
def _click_message_area(self, platform: str):
|
||||
"""SendMessage 点击消息区(靠右)聚焦消息列表,不移动物理鼠标。
|
||||
def _focus_message_area(self, platform: str) -> bool:
|
||||
"""聚焦消息区:Esc清弹窗 → 点击会话列表选对话 → 点击消息区聚焦。
|
||||
|
||||
TIM 三栏布局: 联系人|会话列表|消息区 → 消息区在最右 ~12%。
|
||||
用 SendMessage 同步发送(Electron 响应同步消息),PostMessage 异步 Electron 不处理。
|
||||
两步点击确保焦点落在消息显示区域而非联系人/会话列表。
|
||||
SendMessage 同步发送(Electron 响应同步消息)。
|
||||
不移动物理鼠标。
|
||||
"""
|
||||
hwnd = self._cached_hwnd.get(platform)
|
||||
if not hwnd:
|
||||
return
|
||||
return False
|
||||
try:
|
||||
rect = win32gui.GetWindowRect(hwnd)
|
||||
if not rect:
|
||||
return
|
||||
return False
|
||||
left, top, right, bottom = rect
|
||||
width = right - left
|
||||
height = bottom - top
|
||||
if width < 100 or height < 100:
|
||||
return
|
||||
# 消息区在最右(TIM 三栏: 左~5% 联系人, 中~75% 会话, 右~20% 消息)
|
||||
client_x = int(width * 0.88)
|
||||
client_y = int(height * 0.50)
|
||||
lparam = win32api.MAKELONG(client_x, client_y)
|
||||
# SendMessage 同步 — Electron 能正确处理
|
||||
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
|
||||
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
|
||||
w = right - left
|
||||
h = bottom - top
|
||||
if w < 100 or h < 100:
|
||||
return False
|
||||
|
||||
# 先从前景(_read_via_clipboard_once 已做,但再确保)
|
||||
# Esc 退出搜索/弹出框,恢复默认焦点
|
||||
auto.SendKeys('{Esc}')
|
||||
time.sleep(0.15)
|
||||
auto.SendKeys('{Esc}')
|
||||
time.sleep(0.15)
|
||||
|
||||
# 1. 点击会话列表(左 ~15%)选中一个对话
|
||||
x1 = int(w * 0.15)
|
||||
y1 = int(h * 0.35)
|
||||
lp1 = win32api.MAKELONG(x1, y1)
|
||||
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lp1)
|
||||
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lp1)
|
||||
time.sleep(0.3)
|
||||
logger.debug(f"[{platform}] SendMessage 点击 ({client_x},{client_y})")
|
||||
|
||||
# 2. 点击消息显示区(中右 ~60%)聚焦消息列表
|
||||
x2 = int(w * 0.60)
|
||||
y2 = int(h * 0.40)
|
||||
lp2 = win32api.MAKELONG(x2, y2)
|
||||
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lp2)
|
||||
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lp2)
|
||||
time.sleep(0.3)
|
||||
|
||||
logger.debug(f"[{platform}] 已聚焦消息区 (conv={x1},{y1} msg={x2},{y2})")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"[{platform}] 聚焦消息区失败: {e}")
|
||||
return False
|
||||
|
||||
def _ensure_foreground(self, hwnd, max_retries=3) -> bool:
|
||||
"""确保窗口在前台,重试多次。失败返回 False,不强行发送。"""
|
||||
for i in range(max_retries):
|
||||
self._attach_set_foreground(hwnd)
|
||||
time.sleep(0.2)
|
||||
try:
|
||||
fg = win32gui.GetForegroundWindow()
|
||||
if fg == hwnd:
|
||||
return True
|
||||
# 目标窗口是前台窗口的子窗口也可
|
||||
if fg and win32gui.IsChild(hwnd, fg):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.15)
|
||||
logger.warning(f"无法将窗口 {hwnd} 带到前台,放弃操作")
|
||||
return False
|
||||
|
||||
def _attach_set_foreground(self, hwnd):
|
||||
"""AttachThreadInput + SetForegroundWindow,从 daemon 线程也能成功。"""
|
||||
@@ -338,8 +384,17 @@ class MessageMonitor:
|
||||
|
||||
texts = []
|
||||
try:
|
||||
self._attach_set_foreground(hwnd)
|
||||
time.sleep(0.3)
|
||||
# 确保窗口在前台(失败则返回空,避免发送到错误窗口)
|
||||
if not self._ensure_foreground(hwnd, max_retries=2):
|
||||
return []
|
||||
|
||||
# TIM/QQ NT: Esc 退出搜索/弹框,确保焦点在消息区域
|
||||
variant = self._app_variant.get(platform, "")
|
||||
if variant == "tim":
|
||||
auto.SendKeys('{Esc}')
|
||||
time.sleep(0.15)
|
||||
auto.SendKeys('{Esc}')
|
||||
time.sleep(0.15)
|
||||
|
||||
# Ctrl+A (全选)
|
||||
auto.SendKeys('{Ctrl}a')
|
||||
@@ -421,11 +476,13 @@ class MessageMonitor:
|
||||
logger.warning(f"[{platform}] 找不到搜索框")
|
||||
return False
|
||||
|
||||
# 恢复窗口并前置
|
||||
# 恢复窗口并前置(失败则放弃搜索)
|
||||
if win32gui.IsIconic(hwnd):
|
||||
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
|
||||
self._attach_set_foreground(hwnd)
|
||||
time.sleep(0.3)
|
||||
if not self._ensure_foreground(hwnd):
|
||||
logger.warning(f"[{platform}] 无法前置窗口,放弃搜索联系人")
|
||||
return False
|
||||
time.sleep(0.2)
|
||||
|
||||
# SetFocus 到搜索框(不移动鼠标)
|
||||
search.SetFocus()
|
||||
@@ -472,11 +529,16 @@ class MessageMonitor:
|
||||
wc.SetClipboardText(text, wc.CF_UNICODETEXT)
|
||||
wc.CloseClipboard()
|
||||
|
||||
# 窗口前置
|
||||
# 确保窗口可见且在前台(处理托盘/最小化状态)
|
||||
if not win32gui.IsWindowVisible(hwnd):
|
||||
win32gui.ShowWindow(hwnd, win32con.SW_SHOWNORMAL)
|
||||
time.sleep(0.2)
|
||||
if win32gui.IsIconic(hwnd):
|
||||
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
|
||||
self._attach_set_foreground(hwnd)
|
||||
time.sleep(0.3)
|
||||
time.sleep(0.2)
|
||||
if not self._ensure_foreground(hwnd):
|
||||
logger.error(f"[{platform}] 无法带到前台,放弃发送")
|
||||
return False
|
||||
|
||||
# PostMessage 点击输入框区域(底部)— 不移动物理鼠标
|
||||
rect = win32gui.GetWindowRect(hwnd)
|
||||
@@ -522,9 +584,14 @@ class MessageMonitor:
|
||||
def _read_messages(self, window, platform: str = "") -> list:
|
||||
"""每轮重新定位 ListControl。"""
|
||||
if platform == "qq":
|
||||
# QQ NT/TIM Electron: UIA 不可遍历,剪贴板法+Win32回退
|
||||
# 先 SendMessage 点击消息区确保焦点位置
|
||||
self._click_message_area(platform)
|
||||
variant = self._app_variant.get(platform, "")
|
||||
if variant == "qq_nt":
|
||||
# QQ NT: UIA 暴露 ListControl("消息"),先尝试 UIA 读取
|
||||
texts = self._read_message_list(window)
|
||||
if texts:
|
||||
return texts
|
||||
# TIM / QQ NT UIA 缓存失效: 聚焦消息区 → 剪贴板
|
||||
self._focus_message_area(platform)
|
||||
texts = self._read_via_clipboard(platform)
|
||||
if texts:
|
||||
return texts
|
||||
@@ -740,6 +807,10 @@ class MessageMonitor:
|
||||
# ── 单次轮询 ─────────────────────────────────────────────
|
||||
|
||||
def _poll(self, platform: str):
|
||||
# 自动回复发送中跳过轮询(防止窗口焦点被抢)
|
||||
if self._sending_event.is_set():
|
||||
return
|
||||
|
||||
counter = self._poll_count.get(platform, 0) + 1
|
||||
self._poll_count[platform] = counter
|
||||
|
||||
|
||||
Reference in New Issue
Block a user