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:
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user