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