fix: 剪贴板hash去重 + 双击ESC急停 + 截图日志完善

1. 剪贴板hash去重: _read_via_clipboard_once计算md5(content),
   同平台内容未变则跳过,防止QQ读到微信遗留的过时剪贴板数据
   答非所问(跨平台stale数据)问题修复

2. 双击ESC急停: _esc_check_loop daemon线程每150ms轮询ESC键,
   两次ESC间隔<500ms触发_emergency_stop: 设置_running=False、
   杀死EasyOCR子进程、清空发送锁、回调GUI更新状态、
   用户可点击开关重新启动。急停后不退出程序。

3. 截图日志完善:
   - 日志打印完整路径(用户可直接定位)
   - 统一keep=60(QQ+微信共享清理池,双平台不互删)
   - 移除微信冗余二次保存(wechat_ocr_*重复save)
   - 修复silent except→打出save失败日志
This commit is contained in:
youpaishidifu
2026-07-29 13:11:00 +08:00
parent afbcd109f0
commit b2dc72e7b5
2 changed files with 102 additions and 19 deletions
+16
View File
@@ -43,6 +43,7 @@ class MsgBotApp(ctk.CTk):
wechat=self.cfg.get("wechat_enabled", True), wechat=self.cfg.get("wechat_enabled", True),
qq=self.cfg.get("qq_enabled", False), qq=self.cfg.get("qq_enabled", False),
) )
self.monitor.set_emergency_callback(self._on_emergency_stop)
# 自动回复 # 自动回复
self.ar = AutoReply(self.monitor) self.ar = AutoReply(self.monitor)
@@ -392,6 +393,21 @@ class MsgBotApp(ctk.CTk):
self._log_text.delete("1.0", "end") self._log_text.delete("1.0", "end")
self._log_text.configure(state="disabled") self._log_text.configure(state="disabled")
# ── 急停回调 ──
def _on_emergency_stop(self):
"""双击 ESC 急停的回调(从 daemon 线程调用,日志队列安全)。"""
self._log_queue.put("⚠️⚠️⚠️ 双击ESC急停!消息监控已强制终止")
# 安全更新 toggle 状态(主线程 after 调度)
self.after(0, self._update_stop_ui)
def _update_stop_ui(self):
"""主线程更新急停后的 UI。"""
self.monitor.stop()
self._toggle_btn.deselect()
self._status_label.configure(text="⏸ 急停")
self._log_queue.put("🔄 可点击「启用监控」重新启动")
# ── 窗口关闭 ── # ── 窗口关闭 ──
def _on_close(self): def _on_close(self):
+86 -19
View File
@@ -210,6 +210,13 @@ class MessageMonitor:
self._easyocr_lock = threading.Lock() self._easyocr_lock = threading.Lock()
# QQ 独立轮询线程(不阻塞微信主循环) # QQ 独立轮询线程(不阻塞微信主循环)
self._qq_thread: Optional[threading.Thread] = None self._qq_thread: Optional[threading.Thread] = None
# 双击 ESC 急停线程
self._emergency_thread: Optional[threading.Thread] = None
# 剪贴板内容 hash(防止跨平台读到过时数据)
self._last_clipboard_hash: dict = {} # platform → str(hash)
# 紧急停止回调(通知 GUI 更新状态)
self._emergency_stop_callback: Optional[Callable[[], None]] = None
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)
@@ -221,6 +228,9 @@ class MessageMonitor:
def set_callback(self, cb: Callable[[str, str], None]): def set_callback(self, cb: Callable[[str, str], None]):
self._on_message = cb self._on_message = cb
def set_emergency_callback(self, cb: Callable[[], None]):
self._emergency_stop_callback = cb
def start(self): def start(self):
if self._running: if self._running:
return return
@@ -231,6 +241,9 @@ class MessageMonitor:
# QQ 独立轮询线程,不阻塞微信主循环(WeChat EasyOCR ~15-20s 每轮) # QQ 独立轮询线程,不阻塞微信主循环(WeChat EasyOCR ~15-20s 每轮)
self._qq_thread = threading.Thread(target=self._qq_poll_loop, daemon=True, name="monitor-qq") self._qq_thread = threading.Thread(target=self._qq_poll_loop, daemon=True, name="monitor-qq")
self._qq_thread.start() self._qq_thread.start()
# 双击 ESC 急停检测线程
self._emergency_thread = threading.Thread(target=self._esc_check_loop, daemon=True, name="monitor-esc")
self._emergency_thread.start()
logger.info("消息监控已启动") logger.info("消息监控已启动")
def stop(self): def stop(self):
@@ -241,6 +254,9 @@ class MessageMonitor:
if self._qq_thread and self._qq_thread.is_alive(): if self._qq_thread and self._qq_thread.is_alive():
self._qq_thread.join(timeout=3) self._qq_thread.join(timeout=3)
self._qq_thread = None self._qq_thread = None
if self._emergency_thread and self._emergency_thread.is_alive():
self._emergency_thread.join(timeout=1)
self._emergency_thread = None
self._cached_hwnd.clear() self._cached_hwnd.clear()
self._cached_pid.clear() self._cached_pid.clear()
self._last_sig.clear() self._last_sig.clear()
@@ -294,6 +310,58 @@ class MessageMonitor:
logger.error(f"QQ轮询异常: {e}\n{traceback.format_exc()}") logger.error(f"QQ轮询异常: {e}\n{traceback.format_exc()}")
time.sleep(self._poll_interval) time.sleep(self._poll_interval)
# ── 双击 ESC 急停 ──────────────────────────────────────
def _esc_check_loop(self):
"""独立 daemon 线程:GetAsyncKeyState 检测双击 ESC → 强制急停。
用户 bot 卡住或需强制接管时,双击 ESC 即可终止所有操作:
- 设置 _running=False 退出所有轮询循环
- 杀死 EasyOCR 子进程(GPU 推理可能卡死)
- 清空发送锁(解锁被 hang 的发送操作)
- 通知 GUI 更新状态按钮
"""
VK_ESC = 0x1B
MAX_INTERVAL = 0.5 # 两次 ESC 间隔 < 500ms 算双击
last_press = 0.0
while True:
try:
if ctypes.windll.user32.GetAsyncKeyState(VK_ESC) & 0x8000:
now = time.time()
if last_press > 0 and (now - last_press) < MAX_INTERVAL:
logger.warning("⚠️ 双击ESC急停触发!强制终止所有操作...")
self._emergency_stop()
return # 线程退出
last_press = now
except Exception:
pass
time.sleep(0.15)
def _emergency_stop(self):
"""强制停止监控+杀死 EasyOCR+清空发送锁。"""
self._running = False
# 释放发送锁(防止被 hang 的发送操作卡住)
self._sending_event.clear()
# 杀死 EasyOCR 子进程(GPU 推理可能卡死不响应)
try:
if self._easyocr_proc and self._easyocr_proc.poll() is None:
self._easyocr_proc.kill()
self._easyocr_proc.wait(timeout=2)
logger.warning("[急停] EasyOCR 子进程已杀死")
except Exception as e:
logger.debug(f"[急停] 杀 EasyOCR 异常: {e}")
# 通知回调(GUI 更新状态)
try:
if self._emergency_stop_callback:
self._emergency_stop_callback()
except Exception as e:
logger.debug(f"[急停] 回调异常: {e}")
logger.warning("⚠️ 急停完成 — 消息监控已停止,点击开关可重新启动")
# ── 窗口定位 ───────────────────────────────────────────── # ── 窗口定位 ─────────────────────────────────────────────
def _ensure_hwnd(self, platform: str) -> Optional[int]: def _ensure_hwnd(self, platform: str) -> Optional[int]:
@@ -1003,10 +1071,10 @@ class MessageMonitor:
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19] ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_dbg_path = os.path.join(script_dir, f"ocr_debug_qq_{ts}.png") _dbg_path = os.path.join(script_dir, f"ocr_debug_qq_{ts}.png")
im.save(_dbg_path) im.save(_dbg_path)
logger.debug(f"[qq] 调试截图: {os.path.basename(_dbg_path)}") logger.debug(f"[qq] 调试截图: {_dbg_path}")
_clean_old_ocr_debug(script_dir, keep=30) _clean_old_ocr_debug(script_dir, keep=60)
except Exception: except Exception as e:
pass logger.debug(f"[qq] 调试截图保存失败: {e}")
# ⭐ 颜色像素检测:如果截图中只有 bot 蓝色气泡,无白色用户消息,跳过 # ⭐ 颜色像素检测:如果截图中只有 bot 蓝色气泡,无白色用户消息,跳过
if not self._has_user_colors(im, "qq"): if not self._has_user_colors(im, "qq"):
@@ -1134,11 +1202,11 @@ class MessageMonitor:
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19] ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_dbg_path = os.path.join(script_dir, f"ocr_debug_wx_{ts}.png") _dbg_path = os.path.join(script_dir, f"ocr_debug_wx_{ts}.png")
im.save(_dbg_path) im.save(_dbg_path)
logger.debug(f"[wechat] 调试截图: {os.path.basename(_dbg_path)}") logger.debug(f"[wechat] 调试截图: {_dbg_path}")
# 清理 wechat 和 qq 两种前缀的调试图 # 清理 ocr_debug_* 调试图(unified pool)
_clean_old_ocr_debug(script_dir, keep=30) _clean_old_ocr_debug(script_dir, keep=60)
except Exception: except Exception as e:
pass logger.debug(f"[wechat] 调试截图保存失败: {e}")
# ⭐ 颜色像素检测:如果截图中只有 bot 绿色气泡,无白色用户消息,跳过 # ⭐ 颜色像素检测:如果截图中只有 bot 绿色气泡,无白色用户消息,跳过
if not self._has_user_colors(im, "wechat"): if not self._has_user_colors(im, "wechat"):
@@ -1160,16 +1228,6 @@ class MessageMonitor:
self._last_ocr_time["wechat"] = now self._last_ocr_time["wechat"] = now
self._ocr_unchanged.pop("wechat", None) self._ocr_unchanged.pop("wechat", None)
# 保存调试截图
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
path = os.path.join(script_dir, f"wechat_ocr_{ts}.png")
im.save(path)
_clean_old_ocr_debug(script_dir, keep=20)
except Exception as e:
logger.debug(f"[wechat] OCR 保存截屏失败: {e}")
# 图像预处理(同 QQ) # 图像预处理(同 QQ)
try: try:
im = im.resize((im.width * 2, im.height * 2), Image.LANCZOS) im = im.resize((im.width * 2, im.height * 2), Image.LANCZOS)
@@ -1710,6 +1768,15 @@ class MessageMonitor:
logger.debug(f"[{platform}] 剪贴板读取 → {len(raw_lines)} lines") logger.debug(f"[{platform}] 剪贴板读取 → {len(raw_lines)} lines")
# ⭐ 剪贴板 hash:内容没变化 → 跳过(防止跨平台读到过时数据)
import hashlib
cur_h = hashlib.md5(content.encode('utf-8') if content else b'').hexdigest()
last_h = self._last_clipboard_hash.get(platform, '')
if cur_h == last_h:
logger.debug(f"[{platform}] 剪贴板内容未变,跳过 stale 数据")
return []
self._last_clipboard_hash[platform] = cur_h
# ── 过滤:只返回"像用户消息"的行 ── # ── 过滤:只返回"像用户消息"的行 ──
sender_pattern = re.compile(r'^[\u4e00-\u9fff·A-Za-z0-9_\-]{1,10}$') sender_pattern = re.compile(r'^[\u4e00-\u9fff·A-Za-z0-9_\-]{1,10}$')
for line in raw_lines: for line in raw_lines: