修死循环:全局去重+取消QQ键盘导航切会话+过滤系统消息

This commit is contained in:
youpaishidifu
2026-07-25 16:58:09 +08:00
parent 6a96111db6
commit 0927603fc3
2 changed files with 188 additions and 53 deletions
+4
View File
@@ -355,7 +355,11 @@ class MsgBotApp(ctk.CTk):
try: try:
while True: while True:
data = self._reply_queue.get_nowait() data = self._reply_queue.get_nowait()
try:
self._execute_reply(data) self._execute_reply(data)
except Exception as e:
logger.error(f"自动回复执行异常: {e}", exc_info=True)
self.monitor._sending_event.clear()
except queue.Empty: except queue.Empty:
pass pass
self.after(200, self._flush_reply_queue) self.after(200, self._flush_reply_queue)
+182 -51
View File
@@ -54,6 +54,11 @@ class MessageMonitor:
# 应用变体:qq→"qq_nt"或"tim" # 应用变体:qq→"qq_nt"或"tim"
self._app_variant: dict = {} self._app_variant: dict = {}
# 已处理消息的全局持久化集合(跨平台、跨轮次,防回环)
self._seen_messages: set = set()
# 上次回复时间戳(冷却期内跳过该平台 poll)
self._last_reply_time: dict = {} # platform → timestamp
def set_poll_interval(self, seconds: float): def set_poll_interval(self, seconds: float):
self._poll_interval = max(0.5, seconds) self._poll_interval = max(0.5, seconds)
@@ -103,9 +108,9 @@ class MessageMonitor:
if self._wechat_enabled and not self._sending_event.is_set(): if self._wechat_enabled and not self._sending_event.is_set():
self._poll("wechat") self._poll("wechat")
if self._qq_enabled: if self._qq_enabled:
# QQ 降频:每 10 周期 poll 一次(~20s) # QQ 降频:每 3 周期 poll 一次(~6s)
self._qq_skip_counter += 1 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._qq_skip_counter = 0
self._poll("qq") self._poll("qq")
except Exception as e: except Exception as e:
@@ -237,13 +242,67 @@ class MessageMonitor:
except Exception as e: except Exception as e:
logger.debug(f"[{platform}] 滚动到底失败: {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,不强行发送。""" """确保窗口在前台,重试多次。失败返回 False,不强行发送。"""
# 确保用顶层窗口句柄(与 GetForegroundWindow 返回值一致)
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT) root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
for i in range(max_retries): if not root_hwnd or not win32gui.IsWindow(root_hwnd):
self._attach_set_foreground(root_hwnd) logger.warning(f"[前台] 无效句柄 {hwnd}")
return False
# 前置条件:最小化则恢复,隐藏则显示
if win32gui.IsIconic(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE)
time.sleep(0.2) time.sleep(0.2)
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.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: try:
fg = win32gui.GetForegroundWindow() fg = win32gui.GetForegroundWindow()
if fg == root_hwnd: if fg == root_hwnd:
@@ -252,17 +311,6 @@ class MessageMonitor:
return True return True
except Exception: except Exception:
pass pass
# fallback: SwitchToThisWindow(比 SetForegroundWindow 权限要求低)
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)):
return True
except Exception:
pass
time.sleep(0.15)
logger.warning(f"无法将窗口 {hwnd} 带到前台,放弃操作")
return False return False
def _attach_set_foreground(self, hwnd): def _attach_set_foreground(self, hwnd):
@@ -352,24 +400,21 @@ class MessageMonitor:
if not self._ensure_foreground(hwnd, max_retries=2): if not self._ensure_foreground(hwnd, max_retries=2):
return [] return []
# TIM: 键盘导航到消息区域 # 注意:不再执行 {Esc}{Esc}{Down}{Enter} 键盘导航,避免每次 poll
variant = self._app_variant.get(platform, "") # 切换到不同会话导致读到不同消息集。直接 Ctrl+A/C 读取当前可见对话。
if variant == "tim": # 若需滚动到底部,用 PostMessage(不影响焦点)
auto.SendKeys('{Esc}') hwnd_current = self._cached_hwnd.get(platform)
time.sleep(0.1) if hwnd_current:
auto.SendKeys('{Esc}') win32gui.PostMessage(hwnd_current, win32con.WM_VSCROLL, win32con.SB_BOTTOM, 0)
time.sleep(0.1)
# 确保选中一个对话并打开(加载右侧消息)
auto.SendKeys('{Down}')
time.sleep(0.15)
auto.SendKeys('{Enter}')
time.sleep(0.5) # 等消息加载
# 用 SendInput 替代 auto.SendKeys(避免 ord() Unicode 解析 bug)
VK_A = 0x41
VK_C = 0x43
# Ctrl+A (全选) # Ctrl+A (全选)
auto.SendKeys('{Ctrl}a') self._send_ctrl_combo(VK_A)
time.sleep(0.15) time.sleep(0.15)
# Ctrl+C (复制) # Ctrl+C (复制)
auto.SendKeys('{Ctrl}c') self._send_ctrl_combo(VK_C)
time.sleep(0.3) time.sleep(0.3)
wc.OpenClipboard() wc.OpenClipboard()
@@ -473,8 +518,57 @@ class MessageMonitor:
logger.error(f"[{platform}] 搜索联系人失败: {e}") logger.error(f"[{platform}] 搜索联系人失败: {e}")
return False 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+<key>: 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: def _paste_and_send(self, platform: str, text: str) -> bool:
"""粘贴文本到输入框并发送(剪贴板 + Ctrl+V + Enter)。""" """粘贴文本到输入框并发送(剪贴板 + SendInput)。"""
hwnd = self._cached_hwnd.get(platform) hwnd = self._cached_hwnd.get(platform)
if not hwnd: if not hwnd:
return False return False
@@ -522,20 +616,44 @@ class MessageMonitor:
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam) win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
time.sleep(0.2) time.sleep(0.2)
# 清空输入框 + 粘贴 + 发送 # 用 SendInput (keybd_event) 替代 auto.SendKeys
auto.SendKeys('{Ctrl}a') # 避免 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) time.sleep(0.05)
auto.SendKeys('{Backspace}') # Backspace (清空)
self._key_down(VK_BACK)
self._key_up(VK_BACK)
time.sleep(0.05) time.sleep(0.05)
auto.SendKeys('{Ctrl}v') # Ctrl+V (粘贴)
self._send_ctrl_combo(VK_V)
time.sleep(0.3) time.sleep(0.3)
auto.SendKeys('{Enter}') # Enter (发送)
self._key_down(VK_RETURN)
self._key_up(VK_RETURN)
time.sleep(0.5) time.sleep(0.5)
logger.info(f"[{platform}] 已回复: {text[:60]}") 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 return True
except Exception as e: except Exception as e:
logger.error(f"[{platform}] 回复失败: {e}") logger.error(f"[{platform}] 回复失败: {e}\n{traceback.format_exc()}")
return False return False
finally: finally:
# 恢复原始剪贴板 # 恢复原始剪贴板
@@ -553,14 +671,9 @@ class MessageMonitor:
def _read_messages(self, window, platform: str = "") -> list: def _read_messages(self, window, platform: str = "") -> list:
"""每轮重新定位 ListControl。""" """每轮重新定位 ListControl。"""
if platform == "qq": if platform == "qq":
variant = self._app_variant.get(platform, "") # QQ NT UIA ListControl 缓存易过期,跳过 UIA 直接走剪贴板
if variant == "qq_nt": # 使用 _once 避免 _read_via_clipboard 的双重重试(减少前台抢占)
# QQ NT: UIA 暴露 ListControl("消息"),先尝试 UIA 读取 texts = self._read_via_clipboard_once(platform)
texts = self._read_message_list(window)
if texts:
return texts
# TIM / QQ NT UIA 缓存失效: 剪贴板法(内部含前置+键盘聚焦)
texts = self._read_via_clipboard(platform)
if texts: if texts:
return texts return texts
@@ -779,6 +892,11 @@ class MessageMonitor:
if self._sending_event.is_set(): if self._sending_event.is_set():
return 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 counter = self._poll_count.get(platform, 0) + 1
self._poll_count[platform] = counter self._poll_count[platform] = counter
@@ -811,12 +929,25 @@ class MessageMonitor:
self._empty_reads[platform] = 0 self._empty_reads[platform] = 0
# 4. 全列表内容比对(找出所有新消息,不只是最后一条) # 4. 用全局 _seen_messages 去重(跨平台、跨轮次防回环)
old_texts = self._last_texts.get(platform, []) seen = self._seen_messages
self._last_texts[platform] = list(texts) 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) # 单次 poll 最多处理 3 条新消息,防止系统消息海量涌入触发回环
new_items = [t.strip() for t in texts if t.strip() and t.strip() not in old_set] MAX_NEW_ITEMS = 3
new_items = new_items[:MAX_NEW_ITEMS]
# 更新 last_texts 供其他逻辑参考(但去重不依赖它)
self._last_texts[platform] = list(texts)
if new_items: if new_items:
# 有纯粹的新文本 → 逐一报告 # 有纯粹的新文本 → 逐一报告