diff --git a/.gitignore b/.gitignore index 81f98e7..d26d5c8 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,7 @@ _radical_*.txt unicode_radicals.md.bak easyocr_worker_state.json easyocr_worker_state.json + +# SQLite temp files +msg_history.db-shm +msg_history.db-wal diff --git a/_ocr_blue_test.py b/_ocr_blue_test.py deleted file mode 100644 index 3c03419..0000000 --- a/_ocr_blue_test.py +++ /dev/null @@ -1,22 +0,0 @@ -import sys -sys.path.insert(0, '.') -from PIL import Image, ImageOps -from winrt.windows.media.ocr import OcrEngine -from winrt.windows.globalization import Language -from winrt.windows.graphics.imaging import SoftwareBitmap, BitmapPixelFormat - -def ocr_image(path): - im = Image.open(path).convert('RGBA') - sb = SoftwareBitmap(BitmapPixelFormat.RGBA8, im.width, im.height) - sb.copy_from_buffer(im.tobytes()) - eng = OcrEngine.try_create_from_language(Language('zh-Hans-CN')) - if not eng: - eng = OcrEngine.try_create_from_user_profile_languages() - res = eng.recognize_async(sb).get() - return res.text - -for p in ['img/raw/qq/_dbg_msg4_blue.png','img/raw/qq/_dbg_msg4_black.png','img/raw/qq/_dbg_msg4_blue6.png']: - try: - print(f'{p}: {ocr_image(p)!r}') - except Exception as e: - print(f'{p}: ERROR {e}') diff --git a/_ocr_crop_test.py b/_ocr_crop_test.py deleted file mode 100644 index 5ad1039..0000000 --- a/_ocr_crop_test.py +++ /dev/null @@ -1,34 +0,0 @@ -import sys -sys.path.insert(0, '.') -from PIL import Image, ImageOps, ImageEnhance -from winrt.windows.media.ocr import OcrEngine -from winrt.windows.globalization import Language -from winrt.windows.graphics.imaging import SoftwareBitmap, BitmapPixelFormat - -def ocr_image(path): - im = Image.open(path).convert('RGBA') - sb = SoftwareBitmap(BitmapPixelFormat.RGBA8, im.width, im.height) - sb.copy_from_buffer(im.tobytes()) - eng = OcrEngine.try_create_from_language(Language('zh-Hans-CN')) - if not eng: - eng = OcrEngine.try_create_from_user_profile_languages() - res = eng.recognize_async(sb).get() - return res.text - -im = Image.open('img/raw/qq/ocr_debug_qq_20260818_162956.261.png').convert('RGB') -# 整条消息区 y=380-740 x=0-739, 温和处理 -for (x0,y0,x1,y1,name,scale) in [ - (0,380,739,740,'bottom_full',2), - (0,540,739,740,'ts_bottom',3), - (0,555,430,665,'ts_msg',4), - (0,665,739,740,'very_bottom',4), -]: - crop = im.crop((x0,y0,x1,y1)) - crop8 = crop.resize((crop.width*scale, crop.height*scale), Image.LANCZOS) - p = f'img/raw/qq/_dbg_{name}.png' - crop8.save(p) - try: - t = ocr_image(p) - print(f'{name}: {t!r}') - except Exception as e: - print(f'{name}: ERROR {e}') \ No newline at end of file diff --git a/monitor.py b/monitor.py index a17e962..f5b34b4 100644 --- a/monitor.py +++ b/monitor.py @@ -402,15 +402,24 @@ class MessageHistory: logger.warning(f"删除旧表失败: {e}") def record_bot_reply(self, platform: str, content: str): - """记录bot回复到bot_messages表""" + """记录bot回复到bot_messages表(环形缓冲区,max 10条/平台)""" content_hash = hashlib.md5(content.strip().encode('utf-8')).hexdigest() conn = self._get_conn() try: - # ⭐ Task #1442: 使用本地时间而不是UTC时间 - # SQLite的CURRENT_TIMESTAMP返回UTC时间,导致时间戳混乱 - # 修复:使用Python本地时间 from datetime import datetime local_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + # ⭐ 环形缓冲区:该平台满10条时删最旧的 + cursor = conn.execute( + "SELECT COUNT(*) FROM bot_messages WHERE platform=?", (platform,) + ) + count = cursor.fetchone()[0] + if count >= 10: + conn.execute( + "DELETE FROM bot_messages WHERE id IN (" + " SELECT id FROM bot_messages WHERE platform=? " + " ORDER BY created_at ASC LIMIT ?" + ")", (platform, count - 9) + ) conn.execute( "INSERT INTO bot_messages (platform, content, content_hash, created_at) VALUES (?, ?, ?, ?)", (platform, content.strip(), content_hash, local_time) @@ -422,14 +431,24 @@ class MessageHistory: self._last_bot_reply_content[platform] = content.strip() def record_user_message(self, platform: str, content: str): - """记录用户消息到user_messages表(用于后续去重)""" + """记录用户消息到user_messages表(环形缓冲区,max 10条/平台)""" content_hash = hashlib.md5(content.strip().encode('utf-8')).hexdigest() conn = self._get_conn() try: - # ⭐ Task #1442: 使用本地时间而不是UTC时间 - # SQLite的CURRENT_TIMESTAMP返回UTC时间,导致时间戳混乱 from datetime import datetime local_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + # ⭐ 环形缓冲区:该平台满10条时删最旧的 + cursor = conn.execute( + "SELECT COUNT(*) FROM user_messages WHERE platform=?", (platform,) + ) + count = cursor.fetchone()[0] + if count >= 10: + conn.execute( + "DELETE FROM user_messages WHERE id IN (" + " SELECT id FROM user_messages WHERE platform=? " + " ORDER BY created_at ASC LIMIT ?" + ")", (platform, count - 9) + ) conn.execute( "INSERT INTO user_messages (platform, content, content_hash, created_at) VALUES (?, ?, ?, ?)", (platform, content.strip(), content_hash, local_time) @@ -761,6 +780,9 @@ class MessageMonitor: self._emergency_stop_callback: Optional[Callable[[], None]] = None # 底层键盘钩子回调引用(防止 GC 回收导致系统崩溃) self._esc_hook_proc = None + # ⭐ 停止信号事件:set() 后所有 time.sleep / 阻塞操作应立即响应退出 + # 替代原 _running bool 标志:Event.wait(timeout) 可被 set() 唤醒,time.sleep 不行 + self._stop_event = threading.Event() def _load_replied_texts_from_db(self): """从消息历史数据库加载最近的bot回复到 _replied_texts, @@ -844,16 +866,18 @@ class MessageMonitor: self._last_clipboard_hash.clear() self._app_variant.clear() # 数据库清理(仅物理重启时) + # ⭐ 2026-08-29 修复:user_messages 和 bot_messages 永不清空 + # 这两张表是bot自言自语防护的数据源,清空会导致查重全失效 + # 行车记录仪模式:满10条复写旧数据(环形缓冲区) if clear_db and self._msg_history: try: conn = self._msg_history._get_conn() - conn.execute("DELETE FROM user_messages") - conn.execute("DELETE FROM bot_messages") + # 只清空临时表,不清用户消息和bot消息 conn.execute("DELETE FROM ocr_staging") conn.execute("DELETE FROM keyword_scores") conn.commit() conn.close() - logger.info("[start] 数据库历史已清空(物理重启模式)") + logger.info("[start] 数据库临时表已清空(物理重启模式),user_messages/bot_messages保留") except Exception as e: logger.error(f"[start] 清空数据库失败: {e}") logger.info("[start] 强制清理所有残留数据完成") @@ -868,6 +892,8 @@ class MessageMonitor: if self._running: return self._running = True + # ⭐ 重置停止信号:确保新轮次不被上一轮残留的 stop_event 阻断 + self._stop_event.clear() # ⭐ 启动时强制清理所有残留数据,避免重复捕捉被时间戳达标而跳过 # full_restart=True 时清空数据库历史(物理重启),False 时仅清内存(急停恢复) self._force_clear_all_residual_data(clear_db=full_restart) @@ -939,6 +965,8 @@ class MessageMonitor: logger.info("消息监控已启动") def stop(self): + # ⭐ 立即触发停止信号:所有 event.wait() 阻塞会被唤醒 + self._stop_event.set() self._running = False if self._thread and self._thread.is_alive(): self._thread.join(timeout=3) @@ -985,6 +1013,12 @@ class MessageMonitor: def is_running(self) -> bool: return self._running + def _interruptible_sleep(self, seconds: float) -> bool: + """可中断的 sleep:stop_event.set() 时立即返回 True(应退出)。 + 正常超时返回 False。 + """ + return self._stop_event.wait(timeout=seconds) + def _release_memory(self): """主动释放内存,交还系统重新分配调度。只保留GUI需要的基本内存。""" import gc @@ -1207,7 +1241,9 @@ class MessageMonitor: heartbeat += 1 if heartbeat % 10 == 0: logger.info(f"[♡] 心跳 #{heartbeat}") - time.sleep(self._poll_interval) + # ⭐ 可中断 sleep:stop_event 触发时立即退出循环 + if self._interruptible_sleep(self._poll_interval): + break def _qq_poll_loop(self): """QQ 独立轮询线程:不阻塞微信 OCR,每轮间隔 ~2s。 @@ -1222,14 +1258,18 @@ class MessageMonitor: # 随机 stagger 3-5s,避免 QQ/微信同时 OCR 抢镜 stagger = random.uniform(3, 5) logger.debug(f"[qq] 轮询延迟 {stagger:.1f}s 与微信错开") - time.sleep(stagger) + # ⭐ 可中断 sleep:stagger 期间也能响应 stop + if self._interruptible_sleep(stagger): + return while self._running: try: if self._qq_enabled and not self._sending_event.is_set(): self._poll("qq") except Exception as e: logger.error(f"QQ轮询异常: {e}\n{traceback.format_exc()}") - time.sleep(self._poll_interval) + # ⭐ 可中断 sleep:stop_event 触发时立即退出循环 + if self._interruptible_sleep(self._poll_interval): + break def _maybe_clean_ocr_dir(self): """OCR 截屏定时清理:每 30~60 秒随机触发一次,删除超过保留期的过期截图。 @@ -1313,6 +1353,8 @@ class MessageMonitor: def _emergency_stop(self): """强制停止监控+杀死 EasyOCR+清空发送锁。""" + # ⭐ 立即触发停止信号:所有 event.wait() 阻塞会被唤醒 + self._stop_event.set() self._running = False # 释放发送锁(防止被 hang 的发送操作卡住) @@ -1695,10 +1737,12 @@ class MessageMonitor: # 1) 恢复最小化 / 显示隐藏窗口 if win32gui.IsIconic(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) - time.sleep(0.2) + if self._interruptible_sleep(0.2): + return False if not win32gui.IsWindowVisible(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) - time.sleep(0.2) + if self._interruptible_sleep(0.2): + return False # 2) 解除系统前台限制(ASFW_ANY),让后台线程也能 SetForegroundWindow try: ctypes.windll.user32.AllowSetForegroundWindow(-1) @@ -1710,12 +1754,14 @@ class MessageMonitor: win32gui.SetForegroundWindow(root_hwnd) except Exception: pass - time.sleep(0.12) + if self._interruptible_sleep(0.12): + return False try: ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) except Exception: pass - time.sleep(0.1) + if self._interruptible_sleep(0.1): + return False if self._check_foreground(root_hwnd): return True # 4) 前台校验失败 —— 但窗口可能仍然可见(用户操作抢走了前台焦点) @@ -1745,10 +1791,12 @@ class MessageMonitor: # 前置条件:最小化则恢复,隐藏则显示 if win32gui.IsIconic(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) - time.sleep(0.2) + if self._interruptible_sleep(0.2): + return False if not win32gui.IsWindowVisible(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) - time.sleep(0.2) + if self._interruptible_sleep(0.2): + return False # 允许任意进程 SetForegroundWindow(移除系统限制,与发送路径一致) try: @@ -1767,14 +1815,16 @@ class MessageMonitor: # Method 1: AttachThreadInput + SetForegroundWindow self._attach_set_foreground(root_hwnd) - time.sleep(0.15) + if self._interruptible_sleep(0.15): + return False if self._check_foreground(root_hwnd): return True # Method 2: SwitchToThisWindow(权限要求低,轻量) try: ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) - time.sleep(0.15) + if self._interruptible_sleep(0.15): + return False if self._check_foreground(root_hwnd): return True except Exception: @@ -4549,11 +4599,13 @@ class MessageMonitor: lparam = win32api.MAKELONG(click_x, click_y) win32gui.PostMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam) win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam) - time.sleep(0.2) + if self._interruptible_sleep(0.2): + return [] # 滚动到底部(PostMessage 安全,不切焦点,确保新消息可见) self._force_scroll_bottom(platform) - time.sleep(0.2) + if self._interruptible_sleep(0.2): + return [] # TIM/QQ NT: 安全键盘导航(无 Esc,不关窗口) # 每步前校验目标窗口仍在焦点,防止 keybd_event 泄露到控制台 @@ -4566,18 +4618,22 @@ class MessageMonitor: # Enter 激活当前对话 if self._fg_is_target(hwnd): ctypes.windll.user32.keybd_event(VK_RETURN, 0, 0, 0) - time.sleep(0.05) + if self._interruptible_sleep(0.05): + return [] ctypes.windll.user32.keybd_event(VK_RETURN, 0, 2, 0) - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return [] # Ctrl+End 滚动到底部(双保险:PostMessage + keybd_event) if self._fg_is_target(hwnd): ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0) ctypes.windll.user32.keybd_event(VK_END, 0, 0, 0) - time.sleep(0.05) + if self._interruptible_sleep(0.05): + return [] ctypes.windll.user32.keybd_event(VK_END, 0, 2, 0) ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0) - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return [] # Ctrl+A (全选) — 仅当目标窗口仍在焦点 # 先检查发送锁:主线程可能正在 _paste_and_send,避免 Ctrl+C 覆盖其剪贴板 @@ -4588,19 +4644,23 @@ class MessageMonitor: if self._fg_is_target(hwnd): ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0) ctypes.windll.user32.keybd_event(VK_A, 0, 0, 0) - time.sleep(0.05) + if self._interruptible_sleep(0.05): + return [] ctypes.windll.user32.keybd_event(VK_A, 0, 2, 0) ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0) - time.sleep(0.15) + if self._interruptible_sleep(0.15): + return [] # Ctrl+C (复制) VK_C = 0x43 if self._fg_is_target(hwnd): ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0) ctypes.windll.user32.keybd_event(VK_C, 0, 0, 0) - time.sleep(0.05) + if self._interruptible_sleep(0.05): + return [] ctypes.windll.user32.keybd_event(VK_C, 0, 2, 0) ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0) - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return [] wc.OpenClipboard() if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT): @@ -8298,15 +8358,19 @@ class MessageMonitor: try: if win32gui.IsIconic(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return False if not win32gui.IsWindowVisible(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return False ctypes.windll.user32.AllowSetForegroundWindow(-1) ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) - time.sleep(0.2) + if self._interruptible_sleep(0.2): + return False win32gui.SetForegroundWindow(root_hwnd) - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return False except Exception as e: logger.debug(f"[{platform}] 窗口唤醒异常: {e}") @@ -8324,15 +8388,19 @@ class MessageMonitor: VK_RETURN = 0x0D try: self._send_ctrl_combo(VK_A) - time.sleep(0.05) + if self._interruptible_sleep(0.05): + return False self._key_down(VK_BACK) self._key_up(VK_BACK) - time.sleep(0.05) + if self._interruptible_sleep(0.05): + return False self._send_ctrl_combo(VK_V) - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return False self._key_down(VK_RETURN) self._key_up(VK_RETURN) - time.sleep(0.5) + if self._interruptible_sleep(0.5): + return False except Exception as e: logger.error(f"[{platform}] keybd_event 发送失败: {e}") return False @@ -8464,17 +8532,20 @@ class MessageMonitor: root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT) if win32gui.IsIconic(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return False if not win32gui.IsWindowVisible(root_hwnd): win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return False # 3) AttachThreadInput + SetForegroundWindow(不发送全局 Alt 键干扰 QQ 焦点) try: self._attach_set_foreground(root_hwnd) except Exception as e: logger.debug(f"[{platform}] _attach_set_foreground 异常: {e}") - time.sleep(0.2) + if self._interruptible_sleep(0.2): + return False # 4) 再 SetForegroundWindow + SwitchToThisWindow try: @@ -8482,7 +8553,8 @@ class MessageMonitor: ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) except Exception as e: logger.debug(f"[{platform}] SetForegroundWindow/SwitchToThisWindow 异常: {e}") - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return False # 5) 验证 foreground — 不成功则用 SendMessage 点击 + keybd_event VK_A = 0x41 @@ -8505,26 +8577,32 @@ class MessageMonitor: lparam = win32api.MAKELONG(click_x, click_y) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam) - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return False # 再试一次 SetForegroundWindow(SendMessage 点击后可能已生效) self._attach_set_foreground(root_hwnd) try: win32gui.SetForegroundWindow(root_hwnd) except Exception: pass - time.sleep(0.2) + if self._interruptible_sleep(0.2): + return False # keybd_event 发送(不依赖 PostMessage——Electron 不吃 PostMessage 键盘) self._send_ctrl_combo(VK_A) - time.sleep(0.05) + if self._interruptible_sleep(0.05): + return False self._key_down(VK_BACK) self._key_up(VK_BACK) - time.sleep(0.05) + if self._interruptible_sleep(0.05): + return False self._send_ctrl_combo(VK_V) - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return False self._key_down(VK_RETURN) self._key_up(VK_RETURN) - time.sleep(0.5) + if self._interruptible_sleep(0.5): + return False logger.info(f"[{platform}] 已回复(SendMessage+keybd): {text[:60]}") # 记录到消息历史数据库 self._msg_history.record_bot_reply(platform, text.strip()) @@ -8540,19 +8618,24 @@ class MessageMonitor: lparam = win32api.MAKELONG(click_x, click_y) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam) - time.sleep(0.2) + if self._interruptible_sleep(0.2): + return False # keybd_event 发送(窗口已在前台) self._send_ctrl_combo(VK_A) - time.sleep(0.05) + if self._interruptible_sleep(0.05): + return False self._key_down(VK_BACK) self._key_up(VK_BACK) - time.sleep(0.05) + if self._interruptible_sleep(0.05): + return False self._send_ctrl_combo(VK_V) - time.sleep(0.3) + if self._interruptible_sleep(0.3): + return False self._key_down(VK_RETURN) self._key_up(VK_RETURN) - time.sleep(0.5) + if self._interruptible_sleep(0.5): + return False logger.info(f"[{platform}] 已回复: {text[:60]}") # 记录到消息历史数据库 self._msg_history.record_bot_reply(platform, text.strip()) @@ -8939,7 +9022,7 @@ class MessageMonitor: def _poll(self, platform: str): # ⚠️ 急停检查:OCR 期间用户可能已按 ESC 急停,必须立即折返 - if not self._running: + if self._stop_event.is_set() or not self._running: return # 自动回复发送中跳过轮询(防止窗口焦点被抢) if self._sending_event.is_set(): @@ -8991,7 +9074,7 @@ class MessageMonitor: texts = self._read_messages(win, platform) # ⚠️ 急停检查:OCR阻塞可能持续数秒,返回后立即检查,丢弃半成品 - if not self._running: + if self._stop_event.is_set() or not self._running: return # ⭐ hash 跳过鲁棒性:记录本轮 OCR 是否产出可处理消息。 @@ -9016,7 +9099,7 @@ class MessageMonitor: # ⚠️ OCR 返回后立即检查急停标志:急停可能在 OCR 阻塞期间触发, # Windows OCR 无法被中断,但后续文本处理可以立即跳过。 - if not self._running: + if self._stop_event.is_set() or not self._running: return if not texts: @@ -9037,7 +9120,7 @@ class MessageMonitor: self._empty_reads[platform] = 0 # ⚠️ 急停检查:OCR返回文本后,处理前再查一次 - if not self._running: + if self._stop_event.is_set() or not self._running: return # ⭐ 修复:如果正在回复中,跳过本轮处理(防止打断后重复触发) @@ -9591,7 +9674,7 @@ class MessageMonitor: new_items = filtered_new_items # ⚠️ 文本处理后再次检查急停:防止急停在过滤阶段触发后仍触发回复 - if not self._running: + if self._stop_event.is_set() or not self._running: return # 更新 last_texts 与签名 @@ -9601,7 +9684,7 @@ class MessageMonitor: if new_items: # ⚠️ 急停检查:处理新消息前最后检查,急停后立即丢弃全部动作 - if not self._running: + if self._stop_event.is_set() or not self._running: return # 清理过期冷却条目(TTL 60 秒) @@ -9805,7 +9888,7 @@ class MessageMonitor: except Exception as e: logger.debug(f"[{platform}] OCR暂存记录失败: {e}") # ⚠️ 急停检查:回复前最后检查,防止急停后仍然发送 - if not self._running: + if self._stop_event.is_set() or not self._running: return if self._on_message: self._on_message(platform, _best_cleaned)