From 0322e2af643d4912ff262e33f6286c179ed2bdcf Mon Sep 17 00:00:00 2001 From: youpaishidifu Date: Sat, 25 Jul 2026 14:37:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DQQ=E6=8A=93=E8=81=94=E7=B3=BB?= =?UTF-8?q?=E4=BA=BA=E5=88=97=E8=A1=A8+=E5=BE=AE=E4=BF=A1=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=9B=9E=E5=A4=8D=E9=94=99=E5=8F=91=E7=BB=99QQ+?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=A4=84=E7=90=86\n\n=E9=97=AE=E9=A2=981:=20?= =?UTF-8?q?QQ/TIM=E6=8A=93=E4=BC=9A=E8=AF=9D=E5=88=97=E8=A1=A8=E8=80=8C?= =?UTF-8?q?=E9=9D=9E=E6=B6=88=E6=81=AF\n=20=20=E6=A0=B9=E5=9B=A0:=20=5Fcli?= =?UTF-8?q?ck=5Fmessage=5Farea=E5=8D=95=E6=AC=A1=E7=82=B9=E5=87=BB(width*0?= =?UTF-8?q?.88)=E8=90=BD=E5=9C=A8=E8=81=94=E7=B3=BB=E4=BA=BA/=E7=BE=A4?= =?UTF-8?q?=E5=88=97=E8=A1=A8\n=20=20=E8=80=8C=E9=9D=9E=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=8C=BA,=E5=AF=BC=E8=87=B4Ctrl+A=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E8=81=94=E7=B3=BB=E4=BA=BA=E5=90=8D=E8=80=8C=E9=9D=9E?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=86=85=E5=AE=B9\n=20=20=E4=BF=AE=E5=A4=8D:?= =?UTF-8?q?=20=5Ffocus=5Fmessage=5Farea=E4=B8=A4=E6=AC=A1SendMessage?= =?UTF-8?q?=E7=82=B9=E5=87=BB\n=20=20=E5=85=88=E7=82=B9=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=88=97=E8=A1=A8(=E5=B7=A615%)=E9=80=89=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E2=86=92=E5=86=8D=E7=82=B9=E6=B6=88=E6=81=AF=E5=8C=BA(?= =?UTF-8?q?=E4=B8=AD=E5=8F=B360%)=E8=81=9A=E7=84=A6=E6=B6=88=E6=81=AF\n=20?= =?UTF-8?q?=20+=20Esc=E5=89=8D=E5=AF=BC=E9=94=AE=E9=80=80=E5=87=BA?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=A8=A1=E5=BC=8F\n\n=E9=97=AE=E9=A2=982:=20?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E8=87=AA=E5=8A=A8=E5=9B=9E=E5=A4=8D=E5=8F=91?= =?UTF-8?q?=E7=BB=99QQ\n=20=20=E6=A0=B9=E5=9B=A0:=20=5Fattach=5Fset=5Ffore?= =?UTF-8?q?ground=E9=9D=99=E9=BB=98=E5=A4=B1=E8=B4=A5=E4=B8=8D=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E7=BB=93=E6=9E=9C\n=20=20SendKeys=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E5=88=B0=E5=BD=93=E5=89=8D=E5=89=8D=E5=8F=B0=E7=AA=97=E5=8F=A3?= =?UTF-8?q?(QQ)\n=20=20=E4=BF=AE=E5=A4=8D:=20=5Fensure=5Fforeground?= =?UTF-8?q?=E4=BD=BF=E7=94=A8GetForegroundWindow=E9=AA=8C=E8=AF=81\n=20=20?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E7=9B=AE=E6=A0=87=E7=AA=97=E5=8F=A3=E7=9C=9F?= =?UTF-8?q?=E6=AD=A3=E5=9C=A8=E5=89=8D=E5=8F=B0,=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=88=99return=20False=E6=94=BE=E5=BC=83=E5=8F=91=E9=80=81\n?= =?UTF-8?q?=20=20+=20=E5=A4=84=E7=90=86=E6=89=98=E7=9B=98/=E6=9C=80?= =?UTF-8?q?=E5=B0=8F=E5=8C=96=E7=AA=97=E5=8F=A3(ShowWindow=20SW=5FSHOWNORM?= =?UTF-8?q?AL)\n\n=E9=97=AE=E9=A2=983:=20=E9=87=8D=E5=A4=8D=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=90=8C1=E6=9D=A1=E6=B6=88=E6=81=AF\n=20=20=E6=A0=B9?= =?UTF-8?q?=E5=9B=A0:=20QQ=E6=AF=8F=E6=AC=A1=E8=AF=BB=E5=88=B0=E4=B8=8D?= =?UTF-8?q?=E5=90=8C=E8=81=94=E7=B3=BB=E4=BA=BA=E5=90=8D=3D=E4=B8=8D?= =?UTF-8?q?=E5=90=8C=E6=96=B0=E6=B6=88=E6=81=AF=3D=E5=8F=8D=E5=A4=8D?= =?UTF-8?q?=E8=A7=A6=E5=8F=91\n=20=20=E4=BF=AE=E5=A4=8D:=20=E2=91=A0?= =?UTF-8?q?=E8=81=9A=E7=84=A6=E6=B6=88=E6=81=AF=E5=8C=BA=E5=90=8E=E8=AF=BB?= =?UTF-8?q?=E5=88=B0=E7=A8=B3=E5=AE=9A=E6=B6=88=E6=81=AF=E5=86=85=E5=AE=B9?= =?UTF-8?q?=20=E2=91=A1=5Fsending=5Fevent=E9=94=81\n=20=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=9B=9E=E5=A4=8D=E5=8F=91=E9=80=81=E6=9C=9F=E9=97=B4?= =?UTF-8?q?=E9=98=BB=E6=AD=A2=E7=9B=91=E6=8E=A7=E8=BD=AE=E8=AF=A2,?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E7=84=A6=E7=82=B9=E8=A2=AB=E6=8A=A2\n\n?= =?UTF-8?q?=E5=85=B6=E4=BB=96:=20=5Fensure=5Fhwnd=E6=A3=80=E6=B5=8BQQ=20NT?= =?UTF-8?q?=20vs=20TIM=E5=8F=98=E4=BD=93\n=20=20=E7=94=A8=E4=BA=8E?= =?UTF-8?q?=E9=80=89=E6=8B=A9UIA=E4=BC=98=E5=85=88=E6=88=96=E5=89=AA?= =?UTF-8?q?=E8=B4=B4=E6=9D=BF=E4=BC=98=E5=85=88=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gui.py | 29 +++++++----- monitor.py | 137 ++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 121 insertions(+), 45 deletions(-) diff --git a/gui.py b/gui.py index c8eec8a..46887c9 100644 --- a/gui.py +++ b/gui.py @@ -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") diff --git a/monitor.py b/monitor.py index e06df91..08a06f5 100644 --- a/monitor.py +++ b/monitor.py @@ -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