feat: 停止信号优先 + DB环形缓冲区 (Task #1617 + #1618)

Task #1617 停止信号优先机制:
- 新增 threading.Event (_stop_event) 作为主停止信号
- 新增 _interruptible_sleep() 包装 Event.wait(timeout)
- 所有关键 time.sleep() 替换为 _interruptible_sleep()
- _poll 检查点同时检测 _stop_event.is_set() 和 _running
- stop()/_emergency_stop() 先 set() 唤醒阻塞线程
- start() 清除残留 stop_event

Task #1618 DB落库修复 + 环形缓冲区:
- user_messages/bot_messages 每平台最多10条 FIFO
- 物理重启时不清空这两张表(仅清空 ocr_staging/keyword_scores)
- 新增 SQLite 临时文件到 .gitignore

其他:
- 删除 _ocr_blue_test.py / _ocr_crop_test.py 调试文件
This commit is contained in:
youpaishidifu
2026-08-29 13:17:58 +08:00
parent 96388d18f9
commit c3f0fa0882
4 changed files with 148 additions and 117 deletions
+4
View File
@@ -73,3 +73,7 @@ _radical_*.txt
unicode_radicals.md.bak unicode_radicals.md.bak
easyocr_worker_state.json easyocr_worker_state.json
easyocr_worker_state.json easyocr_worker_state.json
# SQLite temp files
msg_history.db-shm
msg_history.db-wal
-22
View File
@@ -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}')
-34
View File
@@ -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}')
+144 -61
View File
@@ -402,15 +402,24 @@ class MessageHistory:
logger.warning(f"删除旧表失败: {e}") logger.warning(f"删除旧表失败: {e}")
def record_bot_reply(self, platform: str, content: str): 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() content_hash = hashlib.md5(content.strip().encode('utf-8')).hexdigest()
conn = self._get_conn() conn = self._get_conn()
try: try:
# ⭐ Task #1442: 使用本地时间而不是UTC时间
# SQLite的CURRENT_TIMESTAMP返回UTC时间,导致时间戳混乱
# 修复:使用Python本地时间
from datetime import datetime from datetime import datetime
local_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 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( conn.execute(
"INSERT INTO bot_messages (platform, content, content_hash, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO bot_messages (platform, content, content_hash, created_at) VALUES (?, ?, ?, ?)",
(platform, content.strip(), content_hash, local_time) (platform, content.strip(), content_hash, local_time)
@@ -422,14 +431,24 @@ class MessageHistory:
self._last_bot_reply_content[platform] = content.strip() self._last_bot_reply_content[platform] = content.strip()
def record_user_message(self, platform: str, content: str): 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() content_hash = hashlib.md5(content.strip().encode('utf-8')).hexdigest()
conn = self._get_conn() conn = self._get_conn()
try: try:
# ⭐ Task #1442: 使用本地时间而不是UTC时间
# SQLite的CURRENT_TIMESTAMP返回UTC时间,导致时间戳混乱
from datetime import datetime from datetime import datetime
local_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 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( conn.execute(
"INSERT INTO user_messages (platform, content, content_hash, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO user_messages (platform, content, content_hash, created_at) VALUES (?, ?, ?, ?)",
(platform, content.strip(), content_hash, local_time) (platform, content.strip(), content_hash, local_time)
@@ -761,6 +780,9 @@ class MessageMonitor:
self._emergency_stop_callback: Optional[Callable[[], None]] = None self._emergency_stop_callback: Optional[Callable[[], None]] = None
# 底层键盘钩子回调引用(防止 GC 回收导致系统崩溃) # 底层键盘钩子回调引用(防止 GC 回收导致系统崩溃)
self._esc_hook_proc = None 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): def _load_replied_texts_from_db(self):
"""从消息历史数据库加载最近的bot回复到 _replied_texts, """从消息历史数据库加载最近的bot回复到 _replied_texts,
@@ -844,16 +866,18 @@ class MessageMonitor:
self._last_clipboard_hash.clear() self._last_clipboard_hash.clear()
self._app_variant.clear() self._app_variant.clear()
# 数据库清理(仅物理重启时) # 数据库清理(仅物理重启时)
# ⭐ 2026-08-29 修复:user_messages 和 bot_messages 永不清空
# 这两张表是bot自言自语防护的数据源,清空会导致查重全失效
# 行车记录仪模式:满10条复写旧数据(环形缓冲区)
if clear_db and self._msg_history: if clear_db and self._msg_history:
try: try:
conn = self._msg_history._get_conn() conn = self._msg_history._get_conn()
conn.execute("DELETE FROM user_messages") # 只清空临时表,不清用户消息和bot消息
conn.execute("DELETE FROM bot_messages")
conn.execute("DELETE FROM ocr_staging") conn.execute("DELETE FROM ocr_staging")
conn.execute("DELETE FROM keyword_scores") conn.execute("DELETE FROM keyword_scores")
conn.commit() conn.commit()
conn.close() conn.close()
logger.info("[start] 数据库历史已清空(物理重启模式)") logger.info("[start] 数据库临时表已清空(物理重启模式),user_messages/bot_messages保留")
except Exception as e: except Exception as e:
logger.error(f"[start] 清空数据库失败: {e}") logger.error(f"[start] 清空数据库失败: {e}")
logger.info("[start] 强制清理所有残留数据完成") logger.info("[start] 强制清理所有残留数据完成")
@@ -868,6 +892,8 @@ class MessageMonitor:
if self._running: if self._running:
return return
self._running = True self._running = True
# ⭐ 重置停止信号:确保新轮次不被上一轮残留的 stop_event 阻断
self._stop_event.clear()
# ⭐ 启动时强制清理所有残留数据,避免重复捕捉被时间戳达标而跳过 # ⭐ 启动时强制清理所有残留数据,避免重复捕捉被时间戳达标而跳过
# full_restart=True 时清空数据库历史(物理重启),False 时仅清内存(急停恢复) # full_restart=True 时清空数据库历史(物理重启),False 时仅清内存(急停恢复)
self._force_clear_all_residual_data(clear_db=full_restart) self._force_clear_all_residual_data(clear_db=full_restart)
@@ -939,6 +965,8 @@ class MessageMonitor:
logger.info("消息监控已启动") logger.info("消息监控已启动")
def stop(self): def stop(self):
# ⭐ 立即触发停止信号:所有 event.wait() 阻塞会被唤醒
self._stop_event.set()
self._running = False self._running = False
if self._thread and self._thread.is_alive(): if self._thread and self._thread.is_alive():
self._thread.join(timeout=3) self._thread.join(timeout=3)
@@ -985,6 +1013,12 @@ class MessageMonitor:
def is_running(self) -> bool: def is_running(self) -> bool:
return self._running 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): def _release_memory(self):
"""主动释放内存,交还系统重新分配调度。只保留GUI需要的基本内存。""" """主动释放内存,交还系统重新分配调度。只保留GUI需要的基本内存。"""
import gc import gc
@@ -1207,7 +1241,9 @@ class MessageMonitor:
heartbeat += 1 heartbeat += 1
if heartbeat % 10 == 0: if heartbeat % 10 == 0:
logger.info(f"[♡] 心跳 #{heartbeat}") 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): def _qq_poll_loop(self):
"""QQ 独立轮询线程:不阻塞微信 OCR,每轮间隔 ~2s。 """QQ 独立轮询线程:不阻塞微信 OCR,每轮间隔 ~2s。
@@ -1222,14 +1258,18 @@ class MessageMonitor:
# 随机 stagger 3-5s,避免 QQ/微信同时 OCR 抢镜 # 随机 stagger 3-5s,避免 QQ/微信同时 OCR 抢镜
stagger = random.uniform(3, 5) stagger = random.uniform(3, 5)
logger.debug(f"[qq] 轮询延迟 {stagger:.1f}s 与微信错开") logger.debug(f"[qq] 轮询延迟 {stagger:.1f}s 与微信错开")
time.sleep(stagger) # ⭐ 可中断 sleep:stagger 期间也能响应 stop
if self._interruptible_sleep(stagger):
return
while self._running: while self._running:
try: try:
if self._qq_enabled and not self._sending_event.is_set(): if self._qq_enabled and not self._sending_event.is_set():
self._poll("qq") self._poll("qq")
except Exception as e: except Exception as e:
logger.error(f"QQ轮询异常: {e}\n{traceback.format_exc()}") 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): def _maybe_clean_ocr_dir(self):
"""OCR 截屏定时清理:每 30~60 秒随机触发一次,删除超过保留期的过期截图。 """OCR 截屏定时清理:每 30~60 秒随机触发一次,删除超过保留期的过期截图。
@@ -1313,6 +1353,8 @@ class MessageMonitor:
def _emergency_stop(self): def _emergency_stop(self):
"""强制停止监控+杀死 EasyOCR+清空发送锁。""" """强制停止监控+杀死 EasyOCR+清空发送锁。"""
# ⭐ 立即触发停止信号:所有 event.wait() 阻塞会被唤醒
self._stop_event.set()
self._running = False self._running = False
# 释放发送锁(防止被 hang 的发送操作卡住) # 释放发送锁(防止被 hang 的发送操作卡住)
@@ -1695,10 +1737,12 @@ class MessageMonitor:
# 1) 恢复最小化 / 显示隐藏窗口 # 1) 恢复最小化 / 显示隐藏窗口
if win32gui.IsIconic(root_hwnd): if win32gui.IsIconic(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) 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): if not win32gui.IsWindowVisible(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
time.sleep(0.2) if self._interruptible_sleep(0.2):
return False
# 2) 解除系统前台限制(ASFW_ANY),让后台线程也能 SetForegroundWindow # 2) 解除系统前台限制(ASFW_ANY),让后台线程也能 SetForegroundWindow
try: try:
ctypes.windll.user32.AllowSetForegroundWindow(-1) ctypes.windll.user32.AllowSetForegroundWindow(-1)
@@ -1710,12 +1754,14 @@ class MessageMonitor:
win32gui.SetForegroundWindow(root_hwnd) win32gui.SetForegroundWindow(root_hwnd)
except Exception: except Exception:
pass pass
time.sleep(0.12) if self._interruptible_sleep(0.12):
return False
try: try:
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
except Exception: except Exception:
pass pass
time.sleep(0.1) if self._interruptible_sleep(0.1):
return False
if self._check_foreground(root_hwnd): if self._check_foreground(root_hwnd):
return True return True
# 4) 前台校验失败 —— 但窗口可能仍然可见(用户操作抢走了前台焦点) # 4) 前台校验失败 —— 但窗口可能仍然可见(用户操作抢走了前台焦点)
@@ -1745,10 +1791,12 @@ class MessageMonitor:
# 前置条件:最小化则恢复,隐藏则显示 # 前置条件:最小化则恢复,隐藏则显示
if win32gui.IsIconic(root_hwnd): if win32gui.IsIconic(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) 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): if not win32gui.IsWindowVisible(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
time.sleep(0.2) if self._interruptible_sleep(0.2):
return False
# 允许任意进程 SetForegroundWindow(移除系统限制,与发送路径一致) # 允许任意进程 SetForegroundWindow(移除系统限制,与发送路径一致)
try: try:
@@ -1767,14 +1815,16 @@ class MessageMonitor:
# Method 1: AttachThreadInput + SetForegroundWindow # Method 1: AttachThreadInput + SetForegroundWindow
self._attach_set_foreground(root_hwnd) self._attach_set_foreground(root_hwnd)
time.sleep(0.15) if self._interruptible_sleep(0.15):
return False
if self._check_foreground(root_hwnd): if self._check_foreground(root_hwnd):
return True return True
# Method 2: SwitchToThisWindow(权限要求低,轻量) # Method 2: SwitchToThisWindow(权限要求低,轻量)
try: try:
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) 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): if self._check_foreground(root_hwnd):
return True return True
except Exception: except Exception:
@@ -4549,11 +4599,13 @@ class MessageMonitor:
lparam = win32api.MAKELONG(click_x, click_y) lparam = win32api.MAKELONG(click_x, click_y)
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam) win32gui.PostMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam) win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
time.sleep(0.2) if self._interruptible_sleep(0.2):
return []
# 滚动到底部(PostMessage 安全,不切焦点,确保新消息可见) # 滚动到底部(PostMessage 安全,不切焦点,确保新消息可见)
self._force_scroll_bottom(platform) self._force_scroll_bottom(platform)
time.sleep(0.2) if self._interruptible_sleep(0.2):
return []
# TIM/QQ NT: 安全键盘导航(无 Esc,不关窗口) # TIM/QQ NT: 安全键盘导航(无 Esc,不关窗口)
# 每步前校验目标窗口仍在焦点,防止 keybd_event 泄露到控制台 # 每步前校验目标窗口仍在焦点,防止 keybd_event 泄露到控制台
@@ -4566,18 +4618,22 @@ class MessageMonitor:
# Enter 激活当前对话 # Enter 激活当前对话
if self._fg_is_target(hwnd): if self._fg_is_target(hwnd):
ctypes.windll.user32.keybd_event(VK_RETURN, 0, 0, 0) 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) 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) # Ctrl+End 滚动到底部(双保险:PostMessage + keybd_event)
if self._fg_is_target(hwnd): if self._fg_is_target(hwnd):
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0) ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
ctypes.windll.user32.keybd_event(VK_END, 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_END, 0, 2, 0)
ctypes.windll.user32.keybd_event(VK_CONTROL, 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 (全选) — 仅当目标窗口仍在焦点 # Ctrl+A (全选) — 仅当目标窗口仍在焦点
# 先检查发送锁:主线程可能正在 _paste_and_send,避免 Ctrl+C 覆盖其剪贴板 # 先检查发送锁:主线程可能正在 _paste_and_send,避免 Ctrl+C 覆盖其剪贴板
@@ -4588,19 +4644,23 @@ class MessageMonitor:
if self._fg_is_target(hwnd): if self._fg_is_target(hwnd):
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0) ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
ctypes.windll.user32.keybd_event(VK_A, 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_A, 0, 2, 0)
ctypes.windll.user32.keybd_event(VK_CONTROL, 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 (复制) # Ctrl+C (复制)
VK_C = 0x43 VK_C = 0x43
if self._fg_is_target(hwnd): if self._fg_is_target(hwnd):
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0) ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
ctypes.windll.user32.keybd_event(VK_C, 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_C, 0, 2, 0)
ctypes.windll.user32.keybd_event(VK_CONTROL, 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() wc.OpenClipboard()
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT): if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
@@ -8298,15 +8358,19 @@ class MessageMonitor:
try: try:
if win32gui.IsIconic(root_hwnd): if win32gui.IsIconic(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) 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): if not win32gui.IsWindowVisible(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) 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.AllowSetForegroundWindow(-1)
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
time.sleep(0.2) if self._interruptible_sleep(0.2):
return False
win32gui.SetForegroundWindow(root_hwnd) win32gui.SetForegroundWindow(root_hwnd)
time.sleep(0.3) if self._interruptible_sleep(0.3):
return False
except Exception as e: except Exception as e:
logger.debug(f"[{platform}] 窗口唤醒异常: {e}") logger.debug(f"[{platform}] 窗口唤醒异常: {e}")
@@ -8324,15 +8388,19 @@ class MessageMonitor:
VK_RETURN = 0x0D VK_RETURN = 0x0D
try: try:
self._send_ctrl_combo(VK_A) 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_down(VK_BACK)
self._key_up(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) 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_down(VK_RETURN)
self._key_up(VK_RETURN) self._key_up(VK_RETURN)
time.sleep(0.5) if self._interruptible_sleep(0.5):
return False
except Exception as e: except Exception as e:
logger.error(f"[{platform}] keybd_event 发送失败: {e}") logger.error(f"[{platform}] keybd_event 发送失败: {e}")
return False return False
@@ -8464,17 +8532,20 @@ class MessageMonitor:
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT) root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
if win32gui.IsIconic(root_hwnd): if win32gui.IsIconic(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE) 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): if not win32gui.IsWindowVisible(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL) win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
time.sleep(0.3) if self._interruptible_sleep(0.3):
return False
# 3) AttachThreadInput + SetForegroundWindow(不发送全局 Alt 键干扰 QQ 焦点) # 3) AttachThreadInput + SetForegroundWindow(不发送全局 Alt 键干扰 QQ 焦点)
try: try:
self._attach_set_foreground(root_hwnd) self._attach_set_foreground(root_hwnd)
except Exception as e: except Exception as e:
logger.debug(f"[{platform}] _attach_set_foreground 异常: {e}") logger.debug(f"[{platform}] _attach_set_foreground 异常: {e}")
time.sleep(0.2) if self._interruptible_sleep(0.2):
return False
# 4) 再 SetForegroundWindow + SwitchToThisWindow # 4) 再 SetForegroundWindow + SwitchToThisWindow
try: try:
@@ -8482,7 +8553,8 @@ class MessageMonitor:
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True) ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
except Exception as e: except Exception as e:
logger.debug(f"[{platform}] SetForegroundWindow/SwitchToThisWindow 异常: {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 # 5) 验证 foreground — 不成功则用 SendMessage 点击 + keybd_event
VK_A = 0x41 VK_A = 0x41
@@ -8505,26 +8577,32 @@ class MessageMonitor:
lparam = win32api.MAKELONG(click_x, click_y) lparam = win32api.MAKELONG(click_x, click_y)
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
time.sleep(0.3) if self._interruptible_sleep(0.3):
return False
# 再试一次 SetForegroundWindow(SendMessage 点击后可能已生效) # 再试一次 SetForegroundWindow(SendMessage 点击后可能已生效)
self._attach_set_foreground(root_hwnd) self._attach_set_foreground(root_hwnd)
try: try:
win32gui.SetForegroundWindow(root_hwnd) win32gui.SetForegroundWindow(root_hwnd)
except Exception: except Exception:
pass pass
time.sleep(0.2) if self._interruptible_sleep(0.2):
return False
# keybd_event 发送(不依赖 PostMessage——Electron 不吃 PostMessage 键盘) # keybd_event 发送(不依赖 PostMessage——Electron 不吃 PostMessage 键盘)
self._send_ctrl_combo(VK_A) 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_down(VK_BACK)
self._key_up(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) 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_down(VK_RETURN)
self._key_up(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]}") logger.info(f"[{platform}] 已回复(SendMessage+keybd): {text[:60]}")
# 记录到消息历史数据库 # 记录到消息历史数据库
self._msg_history.record_bot_reply(platform, text.strip()) self._msg_history.record_bot_reply(platform, text.strip())
@@ -8540,19 +8618,24 @@ class MessageMonitor:
lparam = win32api.MAKELONG(click_x, click_y) lparam = win32api.MAKELONG(click_x, click_y)
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
time.sleep(0.2) if self._interruptible_sleep(0.2):
return False
# keybd_event 发送(窗口已在前台) # keybd_event 发送(窗口已在前台)
self._send_ctrl_combo(VK_A) 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_down(VK_BACK)
self._key_up(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) 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_down(VK_RETURN)
self._key_up(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]}") logger.info(f"[{platform}] 已回复: {text[:60]}")
# 记录到消息历史数据库 # 记录到消息历史数据库
self._msg_history.record_bot_reply(platform, text.strip()) self._msg_history.record_bot_reply(platform, text.strip())
@@ -8939,7 +9022,7 @@ class MessageMonitor:
def _poll(self, platform: str): def _poll(self, platform: str):
# ⚠️ 急停检查:OCR 期间用户可能已按 ESC 急停,必须立即折返 # ⚠️ 急停检查:OCR 期间用户可能已按 ESC 急停,必须立即折返
if not self._running: if self._stop_event.is_set() or not self._running:
return return
# 自动回复发送中跳过轮询(防止窗口焦点被抢) # 自动回复发送中跳过轮询(防止窗口焦点被抢)
if self._sending_event.is_set(): if self._sending_event.is_set():
@@ -8991,7 +9074,7 @@ class MessageMonitor:
texts = self._read_messages(win, platform) texts = self._read_messages(win, platform)
# ⚠️ 急停检查:OCR阻塞可能持续数秒,返回后立即检查,丢弃半成品 # ⚠️ 急停检查:OCR阻塞可能持续数秒,返回后立即检查,丢弃半成品
if not self._running: if self._stop_event.is_set() or not self._running:
return return
# ⭐ hash 跳过鲁棒性:记录本轮 OCR 是否产出可处理消息。 # ⭐ hash 跳过鲁棒性:记录本轮 OCR 是否产出可处理消息。
@@ -9016,7 +9099,7 @@ class MessageMonitor:
# ⚠️ OCR 返回后立即检查急停标志:急停可能在 OCR 阻塞期间触发, # ⚠️ OCR 返回后立即检查急停标志:急停可能在 OCR 阻塞期间触发,
# Windows OCR 无法被中断,但后续文本处理可以立即跳过。 # Windows OCR 无法被中断,但后续文本处理可以立即跳过。
if not self._running: if self._stop_event.is_set() or not self._running:
return return
if not texts: if not texts:
@@ -9037,7 +9120,7 @@ class MessageMonitor:
self._empty_reads[platform] = 0 self._empty_reads[platform] = 0
# ⚠️ 急停检查:OCR返回文本后,处理前再查一次 # ⚠️ 急停检查:OCR返回文本后,处理前再查一次
if not self._running: if self._stop_event.is_set() or not self._running:
return return
# ⭐ 修复:如果正在回复中,跳过本轮处理(防止打断后重复触发) # ⭐ 修复:如果正在回复中,跳过本轮处理(防止打断后重复触发)
@@ -9591,7 +9674,7 @@ class MessageMonitor:
new_items = filtered_new_items new_items = filtered_new_items
# ⚠️ 文本处理后再次检查急停:防止急停在过滤阶段触发后仍触发回复 # ⚠️ 文本处理后再次检查急停:防止急停在过滤阶段触发后仍触发回复
if not self._running: if self._stop_event.is_set() or not self._running:
return return
# 更新 last_texts 与签名 # 更新 last_texts 与签名
@@ -9601,7 +9684,7 @@ class MessageMonitor:
if new_items: if new_items:
# ⚠️ 急停检查:处理新消息前最后检查,急停后立即丢弃全部动作 # ⚠️ 急停检查:处理新消息前最后检查,急停后立即丢弃全部动作
if not self._running: if self._stop_event.is_set() or not self._running:
return return
# 清理过期冷却条目(TTL 60 秒) # 清理过期冷却条目(TTL 60 秒)
@@ -9805,7 +9888,7 @@ class MessageMonitor:
except Exception as e: except Exception as e:
logger.debug(f"[{platform}] OCR暂存记录失败: {e}") logger.debug(f"[{platform}] OCR暂存记录失败: {e}")
# ⚠️ 急停检查:回复前最后检查,防止急停后仍然发送 # ⚠️ 急停检查:回复前最后检查,防止急停后仍然发送
if not self._running: if self._stop_event.is_set() or not self._running:
return return
if self._on_message: if self._on_message:
self._on_message(platform, _best_cleaned) self._on_message(platform, _best_cleaned)