修复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:
youpaishidifu
2026-07-25 14:37:54 +08:00
parent cb170d1160
commit 0322e2af64
2 changed files with 121 additions and 45 deletions
+17 -12
View File
@@ -361,22 +361,27 @@ class MsgBotApp(ctk.CTk):
self.after(200, self._flush_reply_queue)
def _execute_reply(self, data: dict):
"""主线程执行自动回复发送。"""
"""主线程执行自动回复发送。加上发送锁防止监控轮询干扰。"""
platform = data["platform"]
answer = data["answer"]
sender = data.get("sender")
if sender:
# 搜索联系人切换会话
found = self.monitor._search_contact(platform, sender)
if not found:
self._log(f"[{platform}] ⚠ 找不到联系人 {sender},当前会话回复")
# 发送(主线程 SetForegroundWindow 可靠)
ok = self.monitor._paste_and_send(platform, answer)
if ok:
self._log(f"[{platform}] 🤖 已自动回复: {answer[:60]}...")
else:
self._log(f"[{platform}] ⚠ 自动回复发送失败")
# 设发送锁,阻止监控轮询切走焦点
self.monitor._sending_event.set()
try:
if sender:
# 搜索联系人切换会话
found = self.monitor._search_contact(platform, sender)
if not found:
self._log(f"[{platform}] ⚠ 找不到联系人 {sender},当前会话回复")
# 发送(主线程 SetForegroundWindow 可靠,且不受监控干扰)
ok = self.monitor._paste_and_send(platform, answer)
if ok:
self._log(f"[{platform}] 🤖 已自动回复: {answer[:60]}...")
else:
self._log(f"[{platform}] ⚠ 自动回复发送失败(目标窗口无法带到前台)")
finally:
self.monitor._sending_event.clear()
def _clear_log(self):
self._log_text.configure(state="normal")
+104 -33
View File
@@ -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