From 0927603fc3e0cfb089ad73374684f393c0418531 Mon Sep 17 00:00:00 2001 From: youpaishidifu Date: Sat, 25 Jul 2026 16:58:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=BB=E5=BE=AA=E7=8E=AF=EF=BC=9A?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E5=8E=BB=E9=87=8D+=E5=8F=96=E6=B6=88QQ?= =?UTF-8?q?=E9=94=AE=E7=9B=98=E5=AF=BC=E8=88=AA=E5=88=87=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?+=E8=BF=87=E6=BB=A4=E7=B3=BB=E7=BB=9F=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gui.py | 6 +- monitor.py | 235 +++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 188 insertions(+), 53 deletions(-) diff --git a/gui.py b/gui.py index 46887c9..113d7cf 100644 --- a/gui.py +++ b/gui.py @@ -355,7 +355,11 @@ class MsgBotApp(ctk.CTk): try: while True: data = self._reply_queue.get_nowait() - self._execute_reply(data) + try: + self._execute_reply(data) + except Exception as e: + logger.error(f"自动回复执行异常: {e}", exc_info=True) + self.monitor._sending_event.clear() except queue.Empty: pass self.after(200, self._flush_reply_queue) diff --git a/monitor.py b/monitor.py index e0e1bca..e2939a3 100644 --- a/monitor.py +++ b/monitor.py @@ -54,6 +54,11 @@ class MessageMonitor: # 应用变体:qq→"qq_nt"或"tim" self._app_variant: dict = {} + # 已处理消息的全局持久化集合(跨平台、跨轮次,防回环) + self._seen_messages: set = set() + # 上次回复时间戳(冷却期内跳过该平台 poll) + self._last_reply_time: dict = {} # platform → timestamp + def set_poll_interval(self, seconds: float): self._poll_interval = max(0.5, seconds) @@ -103,9 +108,9 @@ class MessageMonitor: if self._wechat_enabled and not self._sending_event.is_set(): self._poll("wechat") if self._qq_enabled: - # QQ 降频:每 10 周期 poll 一次(~20s) + # QQ 降频:每 3 周期 poll 一次(~6s) self._qq_skip_counter += 1 - if self._qq_skip_counter >= 10 and not self._sending_event.is_set(): + if self._qq_skip_counter >= 3 and not self._sending_event.is_set(): self._qq_skip_counter = 0 self._poll("qq") except Exception as e: @@ -237,34 +242,77 @@ class MessageMonitor: except Exception as e: logger.debug(f"[{platform}] 滚动到底失败: {e}") - def _ensure_foreground(self, hwnd, max_retries=3) -> bool: + def _ensure_foreground(self, hwnd, max_retries=5) -> bool: """确保窗口在前台,重试多次。失败返回 False,不强行发送。""" - # 确保用顶层窗口句柄(与 GetForegroundWindow 返回值一致) root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT) - for i in range(max_retries): - self._attach_set_foreground(root_hwnd) + if not root_hwnd or not win32gui.IsWindow(root_hwnd): + logger.warning(f"[前台] 无效句柄 {hwnd}") + return False + + # 前置条件:最小化则恢复,隐藏则显示 + if win32gui.IsIconic(root_hwnd): + win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) time.sleep(0.2) - try: - fg = win32gui.GetForegroundWindow() - if fg == root_hwnd: - return True - if fg and win32gui.IsChild(root_hwnd, fg): - return True - except Exception: - pass - # fallback: SwitchToThisWindow(比 SetForegroundWindow 权限要求低) + if not win32gui.IsWindowVisible(root_hwnd): + win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) + time.sleep(0.2) + + for i in range(max_retries): + # Method 1: AttachThreadInput + SetForegroundWindow + self._attach_set_foreground(root_hwnd) + time.sleep(0.25) + if self._check_foreground(root_hwnd): + return True + + # Method 2: SwitchToThisWindow(权限要求低) try: ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) - time.sleep(0.15) - fg = win32gui.GetForegroundWindow() - if fg == root_hwnd or (fg and win32gui.IsChild(root_hwnd, fg)): + time.sleep(0.2) + if self._check_foreground(root_hwnd): return True except Exception: pass + + # Method 3: SetWindowPos 置顶再取消(最后手段) + if i >= 2: + try: + HWND_TOPMOST = -1 + HWND_NOTOPMOST = -2 + SWP_NOSIZE = 1 + SWP_NOMOVE = 2 + ctypes.windll.user32.SetWindowPos( + root_hwnd, HWND_TOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE + ) + time.sleep(0.1) + ctypes.windll.user32.SetWindowPos( + root_hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE + ) + time.sleep(0.15) + if self._check_foreground(root_hwnd): + return True + except Exception: + pass + time.sleep(0.15) + logger.warning(f"无法将窗口 {hwnd} 带到前台,放弃操作") return False + @staticmethod + def _check_foreground(root_hwnd) -> bool: + """检查窗口是否在前台(或其子窗口)。""" + try: + fg = win32gui.GetForegroundWindow() + if fg == root_hwnd: + return True + if fg and win32gui.IsChild(root_hwnd, fg): + return True + except Exception: + pass + return False + def _attach_set_foreground(self, hwnd): """AttachThreadInput + SetForegroundWindow,从 daemon 线程也能成功。""" try: @@ -352,24 +400,21 @@ class MessageMonitor: if not self._ensure_foreground(hwnd, max_retries=2): return [] - # TIM: 键盘导航到消息区域 - variant = self._app_variant.get(platform, "") - if variant == "tim": - auto.SendKeys('{Esc}') - time.sleep(0.1) - auto.SendKeys('{Esc}') - time.sleep(0.1) - # 确保选中一个对话并打开(加载右侧消息) - auto.SendKeys('{Down}') - time.sleep(0.15) - auto.SendKeys('{Enter}') - time.sleep(0.5) # 等消息加载 + # 注意:不再执行 {Esc}{Esc}{Down}{Enter} 键盘导航,避免每次 poll + # 切换到不同会话导致读到不同消息集。直接 Ctrl+A/C 读取当前可见对话。 + # 若需滚动到底部,用 PostMessage(不影响焦点) + hwnd_current = self._cached_hwnd.get(platform) + if hwnd_current: + win32gui.PostMessage(hwnd_current, win32con.WM_VSCROLL, win32con.SB_BOTTOM, 0) + # 用 SendInput 替代 auto.SendKeys(避免 ord() Unicode 解析 bug) + VK_A = 0x41 + VK_C = 0x43 # Ctrl+A (全选) - auto.SendKeys('{Ctrl}a') + self._send_ctrl_combo(VK_A) time.sleep(0.15) # Ctrl+C (复制) - auto.SendKeys('{Ctrl}c') + self._send_ctrl_combo(VK_C) time.sleep(0.3) wc.OpenClipboard() @@ -473,8 +518,57 @@ class MessageMonitor: logger.error(f"[{platform}] 搜索联系人失败: {e}") return False + # ── SendInput 键盘模拟 ───────────────────────────────── + + @staticmethod + def _key_down(vk: int): + """SendInput KEYBDINPUT keydown.""" + ctypes.windll.user32.keybd_event(vk, 0, 0, 0) + + @staticmethod + def _key_up(vk: int): + """SendInput KEYBDINPUT keyup (KEYEVENTF_KEYUP=2).""" + ctypes.windll.user32.keybd_event(vk, 0, 2, 0) + + @staticmethod + def _send_ctrl_combo(vk: int): + """Send Ctrl+: Ctrl down → key down → key up → Ctrl up.""" + VK_CONTROL = 0x11 + ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0) + ctypes.windll.user32.keybd_event(vk, 0, 0, 0) + ctypes.windll.user32.keybd_event(vk, 0, 2, 0) + ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0) + + @staticmethod + def _is_system_message(text: str) -> bool: + """过滤机器人/系统消息,不作为用户消息处理。""" + if not text: + return True + # 系统前缀/标记 + system_markers = [ + "✅", "❌", "📝", "🔍", "📄", "➡", "♡", + "[WECHAT]", "[QQ]", "[wechat]", "[qq]", + ] + for m in system_markers: + if text.startswith(m): + return True + # 系统事件模板 + system_patterns = [ + "已自动回复", "知识库中未找到", "找到相关结果", + "监控已启动", "监控已暂停", "设置已保存", + "🤖", "找不到联系人", "当前会话回复", + "自动回复触发", "匹配度", + ] + for p in system_patterns: + if p in text: + return True + # 纯时间格式(不含消息内容) + if re.match(r'^\d{1,2}:\d{2}', text): + return True + return False + def _paste_and_send(self, platform: str, text: str) -> bool: - """粘贴文本到输入框并发送(剪贴板 + Ctrl+V + Enter)。""" + """粘贴文本到输入框并发送(剪贴板 + SendInput)。""" hwnd = self._cached_hwnd.get(platform) if not hwnd: return False @@ -522,20 +616,44 @@ class MessageMonitor: win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam) time.sleep(0.2) - # 清空输入框 + 粘贴 + 发送 - auto.SendKeys('{Ctrl}a') + # 用 SendInput (keybd_event) 替代 auto.SendKeys + # 避免 uiautomation SendKeys 的 ord() Unicode 解析 bug + VK_A = 0x41 + VK_V = 0x56 + VK_BACK = 0x08 + VK_RETURN = 0x0D + + # Ctrl+A (全选) + self._send_ctrl_combo(VK_A) time.sleep(0.05) - auto.SendKeys('{Backspace}') + # Backspace (清空) + self._key_down(VK_BACK) + self._key_up(VK_BACK) time.sleep(0.05) - auto.SendKeys('{Ctrl}v') + # Ctrl+V (粘贴) + self._send_ctrl_combo(VK_V) time.sleep(0.3) - auto.SendKeys('{Enter}') + # Enter (发送) + self._key_down(VK_RETURN) + self._key_up(VK_RETURN) time.sleep(0.5) logger.info(f"[{platform}] 已回复: {text[:60]}") + # 将回复文本加入全局 seen 集合,多平台去重 + reply_text = text.strip() + self._seen_messages.add(reply_text) + # 添加换行符归一化变体(跨频道转发时会替换 \n → 空格) + if '\n' in reply_text: + self._seen_messages.add(reply_text.replace('\n', ' ')) + self._seen_messages.add(reply_text.replace('\n', ' ').rstrip('.')) + for part in reply_text.split('\n'): + part = part.strip() + if part and len(part) > 3: + self._seen_messages.add(part) + self._last_reply_time[platform] = time.time() return True except Exception as e: - logger.error(f"[{platform}] 回复失败: {e}") + logger.error(f"[{platform}] 回复失败: {e}\n{traceback.format_exc()}") return False finally: # 恢复原始剪贴板 @@ -553,14 +671,9 @@ class MessageMonitor: def _read_messages(self, window, platform: str = "") -> list: """每轮重新定位 ListControl。""" if platform == "qq": - 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 缓存失效: 剪贴板法(内部含前置+键盘聚焦) - texts = self._read_via_clipboard(platform) + # QQ NT UIA ListControl 缓存易过期,跳过 UIA 直接走剪贴板 + # 使用 _once 避免 _read_via_clipboard 的双重重试(减少前台抢占) + texts = self._read_via_clipboard_once(platform) if texts: return texts @@ -779,6 +892,11 @@ class MessageMonitor: if self._sending_event.is_set(): return + # 冷却期:回复后 5 秒内跳过 poll,防止反馈回环 + last_reply = self._last_reply_time.get(platform, 0.0) + if time.time() - last_reply < 5.0: + return + counter = self._poll_count.get(platform, 0) + 1 self._poll_count[platform] = counter @@ -811,12 +929,25 @@ class MessageMonitor: self._empty_reads[platform] = 0 - # 4. 全列表内容比对(找出所有新消息,不只是最后一条) - old_texts = self._last_texts.get(platform, []) - self._last_texts[platform] = list(texts) + # 4. 用全局 _seen_messages 去重(跨平台、跨轮次防回环) + seen = self._seen_messages + new_items = [] + for t in texts: + ts = t.strip() + if (ts and ts not in seen + and not self._is_system_message(ts)): + seen.add(ts) + new_items.append(ts) + # 控制集合大小防内存泄漏 + if len(seen) > 500: + self._seen_messages = set(list(seen)[-300:]) - old_set = set(t.strip() for t in old_texts) - new_items = [t.strip() for t in texts if t.strip() and t.strip() not in old_set] + # 单次 poll 最多处理 3 条新消息,防止系统消息海量涌入触发回环 + MAX_NEW_ITEMS = 3 + new_items = new_items[:MAX_NEW_ITEMS] + + # 更新 last_texts 供其他逻辑参考(但去重不依赖它) + self._last_texts[platform] = list(texts) if new_items: # 有纯粹的新文本 → 逐一报告