修死循环:全局去重+取消QQ键盘导航切会话+过滤系统消息
This commit is contained in:
@@ -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
@@ -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:
|
||||||
# 有纯粹的新文本 → 逐一报告
|
# 有纯粹的新文本 → 逐一报告
|
||||||
|
|||||||
Reference in New Issue
Block a user