1530 lines
63 KiB
Python
1530 lines
63 KiB
Python
"""消息监控 — 全列表指纹对比 + 窗口冻结检测 + ControlFromHandle 强制刷新"""
|
||
|
||
import time
|
||
import logging
|
||
import threading
|
||
import traceback
|
||
from typing import Optional, Callable, List
|
||
|
||
import uiautomation as auto
|
||
import win32gui
|
||
import win32con
|
||
import win32api
|
||
import win32process
|
||
import re
|
||
import ctypes
|
||
import difflib
|
||
import os
|
||
from datetime import datetime
|
||
from PIL import ImageGrab
|
||
from winrt.windows.media.ocr import OcrEngine
|
||
from winrt.windows.globalization import Language
|
||
from winrt.windows.graphics.imaging import SoftwareBitmap, BitmapPixelFormat
|
||
|
||
logging.getLogger("comtypes").setLevel(logging.WARNING)
|
||
logger = logging.getLogger("msgbot.monitor")
|
||
|
||
|
||
class MessageMonitor:
|
||
"""多线程消息监控器"""
|
||
|
||
def __init__(self, on_message: Optional[Callable[[str, str], None]] = None):
|
||
self._running = False
|
||
self._thread: Optional[threading.Thread] = None
|
||
self._poll_interval = 2.0
|
||
self._on_message = on_message
|
||
self._wechat_enabled = True
|
||
self._qq_enabled = False
|
||
|
||
# 全列表签名 & 完整文本列表 (用于新消息比对)
|
||
self._last_sig: dict = {}
|
||
self._last_texts: dict = {}
|
||
# 最新消息文本(供 GUI 展示)
|
||
self._last_msg: dict = {}
|
||
|
||
# 轮询计数
|
||
self._poll_count: dict = {}
|
||
# QQ 轮询跳过计数(每 3 周期 poll 一次)
|
||
self._qq_skip_counter = 0
|
||
|
||
# 窗口句柄缓存(存 HWND,不存 Control 引用)
|
||
self._cached_hwnd: dict = {}
|
||
# 窗口对应进程 ID(验证窗口是否仍属同一进程)
|
||
self._cached_pid: dict = {}
|
||
# 连续相同签名计数(用于冻结检测)
|
||
self._stale_count: dict = {}
|
||
# 连续空读
|
||
self._empty_reads: dict = {}
|
||
# 剪贴板原始文本(用于自动回复提取发送者)
|
||
self._raw_clipboard: dict = {}
|
||
|
||
# 发送中事件锁(防止自动回复时监控轮询干扰)
|
||
self._sending_event = threading.Event()
|
||
# 应用变体:qq→"qq_nt"或"tim"
|
||
self._app_variant: dict = {}
|
||
|
||
# 已处理消息的全局持久化集合(跨平台、跨轮次,防回环)
|
||
self._seen_messages: set = set()
|
||
# 已回复文本的永久集—永不裁切(剪贴板读到自己回复时必拦)
|
||
self._replied_texts: set = set()
|
||
# 上次回复时间戳(冷却期内跳过该平台 poll)
|
||
self._last_reply_time: dict = {} # platform → timestamp
|
||
|
||
# OCR 引擎(Windows 原生,lazy init)
|
||
self._ocr_engine = None
|
||
# 上次 OCR 原始文本(用于增量检测)
|
||
self._ocr_last_text: dict = {}
|
||
|
||
# OCR 垃圾连续计数(连续垃圾超过阈值清缓存重找)
|
||
self._ocr_garbage_count: int = 0
|
||
|
||
def set_poll_interval(self, seconds: float):
|
||
self._poll_interval = max(0.5, seconds)
|
||
|
||
def set_enabled_platforms(self, wechat: bool = True, qq: bool = False):
|
||
self._wechat_enabled = wechat
|
||
self._qq_enabled = qq
|
||
|
||
def set_callback(self, cb: Callable[[str, str], None]):
|
||
self._on_message = cb
|
||
|
||
def start(self):
|
||
if self._running:
|
||
return
|
||
self._running = True
|
||
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="monitor")
|
||
self._thread.start()
|
||
logger.info("消息监控已启动")
|
||
|
||
def stop(self):
|
||
self._running = False
|
||
if self._thread and self._thread.is_alive():
|
||
self._thread.join(timeout=3)
|
||
self._thread = None
|
||
self._cached_hwnd.clear()
|
||
self._cached_pid.clear()
|
||
self._last_sig.clear()
|
||
self._last_msg.clear()
|
||
self._stale_count.clear()
|
||
self._empty_reads.clear()
|
||
self._poll_count.clear()
|
||
logger.info("消息监控已停止")
|
||
|
||
@property
|
||
def is_running(self) -> bool:
|
||
return self._running
|
||
|
||
# ── 轮询主循环 ──────────────────────────────────────────
|
||
|
||
def _run_loop(self):
|
||
# 创建 Windows 消息队列(使 AttachThreadInput / SetForegroundWindow 在 daemon 线程生效)
|
||
try:
|
||
ctypes.windll.user32.PeekMessageW(None, 0, 0, 0, 1) # PM_NOREMOVE
|
||
except Exception:
|
||
pass
|
||
heartbeat = 0
|
||
while self._running:
|
||
try:
|
||
if self._wechat_enabled and not self._sending_event.is_set():
|
||
self._poll("wechat")
|
||
if self._qq_enabled:
|
||
# QQ 降频:每 4 周期 poll 一次(~8s,OCR 计算略重)
|
||
self._qq_skip_counter += 1
|
||
if self._qq_skip_counter >= 4 and not self._sending_event.is_set():
|
||
self._qq_skip_counter = 0
|
||
self._poll("qq")
|
||
except Exception as e:
|
||
logger.error(f"轮询异常: {e}\n{traceback.format_exc()}")
|
||
|
||
heartbeat += 1
|
||
if heartbeat % 10 == 0:
|
||
logger.info(f"[♡] 心跳 #{heartbeat}")
|
||
time.sleep(self._poll_interval)
|
||
|
||
# ── 窗口定位 ─────────────────────────────────────────────
|
||
|
||
def _ensure_hwnd(self, platform: str) -> Optional[int]:
|
||
"""查找窗口并返回 HWND(不缓存 Control)。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
try:
|
||
if hwnd:
|
||
if win32gui.IsWindow(hwnd):
|
||
return hwnd
|
||
except Exception:
|
||
pass
|
||
|
||
self._cached_hwnd.pop(platform, None)
|
||
self._cached_pid.pop(platform, None)
|
||
|
||
if platform == "wechat":
|
||
for name in ["微信", "WeChat"]:
|
||
try:
|
||
w = auto.WindowControl(Name=name)
|
||
if w.Exists(maxSearchSeconds=0.5):
|
||
rect = w.BoundingRectangle
|
||
if rect.width() > 200 and rect.height() > 100:
|
||
h = w.NativeWindowHandle
|
||
# 确保存顶层窗口句柄(与 GetForegroundWindow 返回值一致)
|
||
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
|
||
self._cached_hwnd[platform] = top
|
||
self._cached_pid[platform] = win32process.GetWindowThreadProcessId(top)[1]
|
||
logger.info(f"[{platform}] 窗口已定位: {name} handle={top}")
|
||
return top
|
||
except Exception:
|
||
pass
|
||
elif platform == "qq":
|
||
for name in ["QQ", "TIM"]:
|
||
try:
|
||
w = auto.WindowControl(Name=name)
|
||
if w.Exists(maxSearchSeconds=0.3):
|
||
rect = w.BoundingRectangle
|
||
if rect.width() > 200 and rect.height() > 100:
|
||
h = w.NativeWindowHandle
|
||
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
|
||
self._cached_hwnd[platform] = top
|
||
self._cached_pid[platform] = win32process.GetWindowThreadProcessId(top)[1]
|
||
self._app_variant[platform] = "qq_nt" if name == "QQ" else "tim"
|
||
logger.info(f"[{platform}] 窗口已定位(名称): {name} top={top}")
|
||
return top
|
||
except Exception:
|
||
pass
|
||
# QQ NT/TIM → TXGuiFoundation 类名
|
||
try:
|
||
root = auto.GetRootControl()
|
||
for child in root.GetChildren():
|
||
try:
|
||
if (child.ClassName or "") == "TXGuiFoundation":
|
||
rect = child.BoundingRectangle
|
||
if rect.width() > 200 and rect.height() > 100:
|
||
h = child.NativeWindowHandle
|
||
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
|
||
cname = str(child.Name or "")
|
||
self._app_variant[platform] = "tim" if "TIM" in cname.upper() else "qq_nt"
|
||
self._cached_hwnd[platform] = top
|
||
self._cached_pid[platform] = win32process.GetWindowThreadProcessId(top)[1]
|
||
logger.info(f"[{platform}] 窗口已定位(TXGuiFoundation): {child.Name} top={top}")
|
||
return top
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
# ── 强制刷新 UIA ──────────────────────────────────────────
|
||
|
||
def _fresh_window(self, platform: str):
|
||
"""从缓存 HWND 创建全新的 WindowControl(绕过 UIA 缓存)。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd:
|
||
return None
|
||
try:
|
||
# 用 NativeWindowHandle 重建,保证 UIA 新缓存
|
||
win = auto.ControlFromHandle(hwnd)
|
||
if win is not None:
|
||
return win
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _find_qq_window_fresh(self):
|
||
"""通过类名重新查找 QQ/TIM 窗口,绕过 UIA 缓存(比 ControlFromHandle 更彻底)。"""
|
||
try:
|
||
# 先按窗口名称查找
|
||
for name in ["QQ", "TIM"]:
|
||
try:
|
||
w = auto.WindowControl(Name=name)
|
||
if w.Exists(maxSearchSeconds=0.2):
|
||
rect = w.BoundingRectangle
|
||
if rect.width() > 200 and rect.height() > 100:
|
||
return w
|
||
except Exception:
|
||
pass
|
||
# 按 TXGuiFoundation 类名深度查找
|
||
root = auto.GetRootControl()
|
||
for child in root.GetChildren():
|
||
try:
|
||
if child.ClassName == "TXGuiFoundation":
|
||
rect = child.BoundingRectangle
|
||
if rect.width() > 200 and rect.height() > 100:
|
||
return child
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _force_uia_refresh(self, platform: str):
|
||
"""通过交互强制窗口更新 UIA 控件树。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd:
|
||
return
|
||
try:
|
||
# 方式1: 窗口重绘
|
||
win32gui.InvalidateRect(hwnd, None, True)
|
||
win32gui.UpdateWindow(hwnd)
|
||
# QQ: 避免标题栏点击(不会干扰用户操作)
|
||
if platform == "qq":
|
||
win32gui.PostMessage(hwnd, win32con.WM_VSCROLL, win32con.SB_BOTTOM, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0)
|
||
else:
|
||
# 方式2: 向窗口发送非客户区点击(仅微信)
|
||
win32gui.PostMessage(hwnd, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN
|
||
win32gui.PostMessage(hwnd, 0x00A2, 2, 0) # WM_NCLBUTTONUP
|
||
logger.debug(f"[{platform}] UIA强制刷新")
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] 强制刷新失败: {e}")
|
||
|
||
def _force_scroll_bottom(self, platform: str):
|
||
"""滚动窗口到底部,触发 UI 更新。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd:
|
||
return
|
||
try:
|
||
# 1. WM_VSCROLL SB_BOTTOM
|
||
win32gui.PostMessage(hwnd, win32con.WM_VSCROLL, win32con.SB_BOTTOM, 0)
|
||
# 2. Ctrl+End 快捷键
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_CONTROL, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_CONTROL, 0)
|
||
# 3. InvalidateRect 强制重绘
|
||
win32gui.InvalidateRect(hwnd, None, True)
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] 滚动到底失败: {e}")
|
||
|
||
def _ensure_foreground(self, hwnd, max_retries=5) -> bool:
|
||
"""确保窗口在前台,重试多次。失败返回 False,不强行发送。"""
|
||
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
|
||
if not root_hwnd or not win32gui.IsWindow(root_hwnd):
|
||
logger.warning(f"[前台] 无效句柄 {hwnd}")
|
||
return False
|
||
|
||
# 前置条件:最小化则恢复,隐藏则显示
|
||
if win32gui.IsIconic(root_hwnd):
|
||
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE)
|
||
time.sleep(0.2)
|
||
if not win32gui.IsWindowVisible(root_hwnd):
|
||
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
|
||
time.sleep(0.2)
|
||
|
||
# 允许任意进程 SetForegroundWindow(移除系统限制,与发送路径一致)
|
||
try:
|
||
ctypes.windll.user32.AllowSetForegroundWindow(-1) # ASFW_ANY
|
||
except Exception:
|
||
pass
|
||
|
||
for i in range(max_retries):
|
||
# Method 1: AttachThreadInput + SetForegroundWindow
|
||
self._attach_set_foreground(root_hwnd)
|
||
time.sleep(0.25)
|
||
if self._check_foreground(root_hwnd):
|
||
return True
|
||
|
||
# Method 2: SwitchToThisWindow(权限要求低)
|
||
try:
|
||
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
|
||
time.sleep(0.2)
|
||
if self._check_foreground(root_hwnd):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
# Method 3: SetWindowPos 置顶再取消(最后手段)
|
||
if i >= 2:
|
||
try:
|
||
HWND_TOPMOST = -1
|
||
HWND_NOTOPMOST = -2
|
||
SWP_NOSIZE = 1
|
||
SWP_NOMOVE = 2
|
||
ctypes.windll.user32.SetWindowPos(
|
||
root_hwnd, HWND_TOPMOST, 0, 0, 0, 0,
|
||
SWP_NOMOVE | SWP_NOSIZE
|
||
)
|
||
time.sleep(0.1)
|
||
ctypes.windll.user32.SetWindowPos(
|
||
root_hwnd, HWND_NOTOPMOST, 0, 0, 0, 0,
|
||
SWP_NOMOVE | SWP_NOSIZE
|
||
)
|
||
time.sleep(0.15)
|
||
if self._check_foreground(root_hwnd):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
time.sleep(0.15)
|
||
|
||
logger.warning(f"无法将窗口 {hwnd} 带到前台,放弃操作")
|
||
return False
|
||
|
||
@staticmethod
|
||
def _check_foreground(root_hwnd) -> bool:
|
||
"""检查窗口是否在前台(或其子窗口)。"""
|
||
try:
|
||
fg = win32gui.GetForegroundWindow()
|
||
if fg == root_hwnd:
|
||
return True
|
||
if fg and win32gui.IsChild(root_hwnd, fg):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def _attach_set_foreground(self, hwnd):
|
||
"""AttachThreadInput + SetForegroundWindow,从 daemon 线程也能成功。"""
|
||
try:
|
||
current_tid = ctypes.windll.kernel32.GetCurrentThreadId()
|
||
target_tid = ctypes.windll.user32.GetWindowThreadProcessId(hwnd, None)
|
||
if ctypes.windll.user32.AttachThreadInput(current_tid, target_tid, True):
|
||
try:
|
||
win32gui.SetForegroundWindow(hwnd)
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
ctypes.windll.user32.AttachThreadInput(current_tid, target_tid, False)
|
||
return
|
||
# AttachThreadInput 失败 → 裸 SetForegroundWindow
|
||
try:
|
||
win32gui.SetForegroundWindow(hwnd)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
try:
|
||
win32gui.SetForegroundWindow(hwnd)
|
||
except Exception:
|
||
pass
|
||
|
||
def _read_win32_texts(self, platform: str) -> list:
|
||
"""Win32 API 读窗口文本,递归枚举(depth 不限)。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd:
|
||
return []
|
||
texts = []
|
||
|
||
def enum_callback(child_hwnd, _):
|
||
try:
|
||
text = win32gui.GetWindowText(child_hwnd)
|
||
if text and len(text.strip()) >= 2:
|
||
texts.append(text.strip())
|
||
except Exception:
|
||
pass
|
||
return True
|
||
|
||
try:
|
||
main_text = win32gui.GetWindowText(hwnd)
|
||
if main_text and len(main_text.strip()) >= 2:
|
||
texts.append(main_text.strip())
|
||
win32gui.EnumChildWindows(hwnd, enum_callback, None)
|
||
except Exception:
|
||
pass
|
||
return texts
|
||
|
||
# ── OCR 读屏(QQ 专用,Windows 原生 OCR) ──────────────────
|
||
|
||
def _ocr_ensure_engine(self):
|
||
"""惰性初始化 OCR 引擎(中文简体)。"""
|
||
if self._ocr_engine is None:
|
||
try:
|
||
self._ocr_engine = OcrEngine.try_create_from_language(
|
||
Language('zh-CN')
|
||
)
|
||
if self._ocr_engine:
|
||
logger.info(f"[ocr] OCR引擎就绪: {self._ocr_engine.recognizer_language.display_name}")
|
||
else:
|
||
logger.warning("[ocr] 创建OCR引擎失败")
|
||
except Exception as e:
|
||
logger.error(f"[ocr] OCR引擎初始化异常: {e}")
|
||
return self._ocr_engine
|
||
|
||
@staticmethod
|
||
def _normalize_ocr_text(text: str) -> str:
|
||
"""Windows OCR 给 CJK 字符间强加空格 → 去除这些空格使文本可去重。
|
||
|
||
"问 题 1 : 你 答 : 你 好" → "问题1:你答:你好"
|
||
"你 好 he llo" → "你好 he llo"(ASCII 词内空格保留)
|
||
"""
|
||
# 所有紧邻 CJK/全角字符的空格全部去掉
|
||
# CJK 范围:汉字 (u4e00-) + 扩展 (u3400-) + 全角/兼容 (uff00-)
|
||
text = re.sub(
|
||
r'(?<=[\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef\u3000-\u303f])'
|
||
r'\s+',
|
||
'', text
|
||
)
|
||
text = re.sub(
|
||
r'\s+'
|
||
r'(?=[\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef\u3000-\u303f])',
|
||
'', text
|
||
)
|
||
return text.strip()
|
||
|
||
@staticmethod
|
||
def _is_ocr_console_garbage(text: str) -> bool:
|
||
"""OCR 输出的文本是否明显是控制台/Python 垃圾。
|
||
|
||
当 OCR 截错窗口(如读到终端窗口)时,文本包含:
|
||
- Python 路径/模块名:comtypes, uiautomation, sklearn, jieba, winrt
|
||
- 脚本文件名:monitor.py, main.py
|
||
- 虚拟环境路径:.venv
|
||
- 日志关键词:DEBUG, INFO, WARNING, msgbot
|
||
- 控制台序列:←[, [32m, [0m
|
||
返回 True = 应当丢弃。
|
||
"""
|
||
garbage_patterns = [
|
||
r'comtypes', r'uiautomation', r'sklearn', r'jieba',
|
||
r'winrt\.', r'msgbot\.', r'monitor\.py', r'main\.py',
|
||
r'\.venv', r'Loading model', r'Prefix dict',
|
||
r'WARNING', r'UserWarning', r'Building prefix',
|
||
r'←\[', r'\[32m', r'\[0m', r'\\n',
|
||
]
|
||
for pat in garbage_patterns:
|
||
if re.search(pat, text, re.IGNORECASE):
|
||
return True
|
||
# 全字母数字+标点无中文 → 肯定是非聊天内容
|
||
cjk_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
|
||
if cjk_count == 0 and len(text.strip()) > 20:
|
||
return True
|
||
return False
|
||
|
||
def _validate_qq_window(self, hwnd: int) -> bool:
|
||
"""验证窗口是否确实是 QQ/TIM 进程。
|
||
|
||
检查项(全过才返回 True):
|
||
1. 窗口存在且可见
|
||
2. 窗口未最小化
|
||
3. 所属进程名称匹配 QQ.exe / TIM.exe
|
||
"""
|
||
if not win32gui.IsWindow(hwnd):
|
||
return False
|
||
if not win32gui.IsWindowVisible(hwnd):
|
||
return False
|
||
if win32gui.IsIconic(hwnd):
|
||
# 最小化 → 没崩溃但截屏会截到别处
|
||
return False
|
||
# 验证进程名
|
||
try:
|
||
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||
hproc = win32api.OpenProcess(
|
||
0x0400 | 0x0010, # PROCESS_QUERY_INFORMATION | PROCESS_VM_READ
|
||
False, pid
|
||
)
|
||
if hproc:
|
||
exe_path = win32process.GetModuleFileNameEx(hproc, 0)
|
||
win32api.CloseHandle(hproc)
|
||
exe_name = exe_path.split('\\')[-1].lower() if exe_path else ''
|
||
if exe_name in ('qq.exe', 'tim.exe'):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
@staticmethod
|
||
def _fuzzy_duplicate(new_text: str, seen_set: set, threshold: float = 0.72) -> bool:
|
||
"""检查 new_text 是否与 seen_set 中已有文本相似度超阈值。
|
||
|
||
OCR 输出有噪声(每次勖彐碎片、行首数字、尾部截断不同),精确匹配无法去重。
|
||
用 difflib.SequenceMatcher 的 quick_ratio() 做快速上界剪枝。
|
||
"""
|
||
if not new_text or len(new_text) < 4:
|
||
return False
|
||
for existing in seen_set:
|
||
if abs(len(existing) - len(new_text)) > max(len(existing), len(new_text)) * 0.4:
|
||
# 长度差距超 40%,不可能相似(quick_ratio 上界 = 2*min/max < 阈值)
|
||
continue
|
||
ratio = difflib.SequenceMatcher(None, new_text, existing).quick_ratio()
|
||
if ratio >= threshold:
|
||
return True
|
||
return False
|
||
|
||
def _ocr_read_qq(self) -> list:
|
||
"""OCR 截屏 QQ 消息区域 -> Windows OCR 提取文本。
|
||
包含窗口验证 + 垃圾过滤 + 连续垃圾清缓存。
|
||
"""
|
||
hwnd = self._cached_hwnd.get("qq")
|
||
if not hwnd:
|
||
return []
|
||
if not self._validate_qq_window(hwnd):
|
||
self._ocr_garbage_count += 1
|
||
if self._ocr_garbage_count == 1:
|
||
# 首次失败时详细日志
|
||
visible = win32gui.IsWindowVisible(hwnd) if win32gui.IsWindow(hwnd) else False
|
||
iconic = win32gui.IsIconic(hwnd) if win32gui.IsWindow(hwnd) else False
|
||
logger.debug(f"[qq] OCR: 窗口不可用(visible={visible}, iconic={iconic})")
|
||
if self._ocr_garbage_count >= 3:
|
||
logger.info("[qq] OCR: 窗口不合法连续3次,清缓存")
|
||
self._cached_hwnd.pop("qq", None)
|
||
self._cached_pid.pop("qq", None)
|
||
self._app_variant.pop("qq", None)
|
||
self._ocr_garbage_count = 0
|
||
return []
|
||
engine = self._ocr_ensure_engine()
|
||
if not engine:
|
||
return []
|
||
try:
|
||
rect = win32gui.GetWindowRect(hwnd)
|
||
if not rect:
|
||
return []
|
||
left, top, right, bottom = rect
|
||
win_w = right - left
|
||
win_h = bottom - top
|
||
# QQ NT 窗口布局: ~0-5% 标签栏, ~5-30% 联系人/群列表, ~30-55% 会话列表, ~55-100% 消息面板
|
||
# 先切 QQ 到前台再截屏,避免 CMD 窗口遮挡→OCR 读到终端文字
|
||
self._ensure_foreground(hwnd)
|
||
time.sleep(0.15)
|
||
# 左边界 25%,覆盖消息文字区域(他人消息在面板左侧)
|
||
# 从 c9acd7e 版本恢复:0.35 仅截到时间戳,0.25 才覆盖文字
|
||
cap_left = int(left + win_w * 0.25)
|
||
cap_top = top + max(40, int(win_h * 0.04)) # 标题栏下方
|
||
cap_right = int(left + win_w * 0.95) # 跳过滚动条
|
||
cap_bottom = bottom - max(100, int(win_h * 0.10)) # 输入框上方
|
||
if cap_right <= cap_left or cap_bottom <= cap_top:
|
||
return []
|
||
im = ImageGrab.grab(bbox=(cap_left, cap_top, cap_right, cap_bottom))
|
||
if im.width < 50 or im.height < 50:
|
||
return []
|
||
# 保存截屏到程序根目录用于调试(保留最近 20 张)
|
||
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"ocr_debug_{ts}.png")
|
||
im.save(path)
|
||
# 清理旧文件:只保留最近 20 张
|
||
_clean_old_ocr_debug(script_dir, keep=20)
|
||
except Exception as e:
|
||
logger.debug(f"[ocr] 保存截屏失败: {e}")
|
||
if im.mode != 'RGBA':
|
||
im = im.convert('RGBA')
|
||
sb = SoftwareBitmap(BitmapPixelFormat.RGBA8, im.width, im.height)
|
||
sb.copy_from_buffer(im.tobytes())
|
||
result = engine.recognize_async(sb).get()
|
||
raw_text = result.text.strip()
|
||
if not raw_text:
|
||
return []
|
||
if self._is_ocr_console_garbage(raw_text):
|
||
self._ocr_garbage_count += 1
|
||
if self._ocr_garbage_count >= 3:
|
||
logger.info("[qq] OCR: 垃圾连续3次,清缓存")
|
||
self._cached_hwnd.pop("qq", None)
|
||
self._cached_pid.pop("qq", None)
|
||
self._app_variant.pop("qq", None)
|
||
self._ocr_garbage_count = 0
|
||
return []
|
||
self._ocr_garbage_count = 0
|
||
self._ocr_last_text["qq"] = raw_text
|
||
ocr_lines = [l.strip() for l in raw_text.split('\n') if l.strip()]
|
||
# ⭐ debug: 记录 OCR 原始行内容,定位过滤导致空白的原因
|
||
for i, rl in enumerate(ocr_lines):
|
||
logger.info(f"[ocr] raw[{i}] raw_line<{rl!r}>")
|
||
texts = []
|
||
for line in ocr_lines:
|
||
line = self._normalize_ocr_text(line)
|
||
if not line:
|
||
continue
|
||
# ⭐ QQ NT 界面固定噪声:整行匹配直接跳过
|
||
if line in ("查看更多", "收起", "选择会话开始聊天"):
|
||
continue
|
||
# 连续纯标点+符号(emoji、装饰字符等)
|
||
if re.match(r'^[✿♡♥❤★☆✦✧❀❁❂❃❄❅❆❇❈❉❊❋➠➡➢➣➤.·•●○◦◎◉◍◐◑◒◓◔◕◖◗◢◣◤◥☺☻☼♠♣♦♤♧♡♢♔♕♖♗♘♙🕐-🕧\s]{1,3}$', line):
|
||
continue
|
||
# 去噪:去掉行首未读红点数字(如 "0")和行尾删除按钮误识别(勖彐 等)
|
||
# 否则同一消息每次 OCR 输出微小差异 → 精确去重失效 → 重复触发
|
||
line = re.sub(r'^0+', '', line)
|
||
line = re.sub(r'[勖彐"\'\s\u2000-\u206f]+$', '', line)
|
||
if not line:
|
||
continue
|
||
# 半角全角数字混杂的界面碎片(如 "1.0s 0.35后监控应用设置")
|
||
line = re.sub(r'^\d+[\..]\d+s?\s*\d*[.\.]?\d*', '', line)
|
||
line = re.sub(r'^[\d.\.\s]{2,20}[一-鿿]{1,5}?[,。、]', '', line)
|
||
if not line:
|
||
continue
|
||
if self._is_system_message(line):
|
||
continue
|
||
# ⭐ 纯中文短文本不一定是消息正文——但 1-15 过滤太宽会杀真消息
|
||
# 改为只过滤单字符纯中文,多字符的 UI 噪声由 _is_system_message 处理
|
||
if re.match(r'^[一-鿿]{1}$', line):
|
||
continue
|
||
if re.match(r'^\d{1,2}[::\s]\d{2}', line):
|
||
continue
|
||
if re.match(r'^\d{4}[/-]\d{1,2}', line):
|
||
continue
|
||
texts.append(line)
|
||
logger.debug(f"[qq] OCR -> {len(ocr_lines)} raw, {len(texts)} filtered, "
|
||
f"last=\"{texts[-1][:50] if texts else ''}\"")
|
||
return texts
|
||
except Exception as e:
|
||
logger.debug(f"[qq] OCR失败: {e}")
|
||
return []
|
||
|
||
def _read_via_clipboard_once(self, platform: str) -> list:
|
||
"""剪贴板法单次执行(安全键盘导航:Enter + Ctrl+End + Ctrl+A/C,无 Esc)。
|
||
|
||
去重由 _poll 的 _replied_texts + _seen_messages 负责,本函数只过滤并返回。
|
||
回复回环防御由 _paste_and_send 写入 _replied_texts 承担。
|
||
"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd or not win32gui.IsWindow(hwnd):
|
||
return []
|
||
|
||
import win32clipboard as wc
|
||
|
||
old = None
|
||
try:
|
||
wc.OpenClipboard()
|
||
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
|
||
old = wc.GetClipboardData(wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
except Exception:
|
||
pass
|
||
|
||
texts = []
|
||
raw_lines = [] # 预初始化,防止 content 为空时 UnboundLocalError
|
||
try:
|
||
# 确保窗口在前台(失败则返回空,避免发送到错误窗口)
|
||
# QQ 剪贴板优先路径需要更可靠的前台保证(与发送路径 retries 一致)
|
||
if not self._ensure_foreground(hwnd, max_retries=5):
|
||
return []
|
||
|
||
# PostMessage 点击消息区域(设焦点,不全局,不比鼠标移动)
|
||
rect = win32gui.GetWindowRect(hwnd)
|
||
if rect:
|
||
left, top, right, bottom = rect
|
||
w = right - left
|
||
h = bottom - top
|
||
# QQ NT 消息面板在 ~55-95% 宽度区域
|
||
click_x = int(w * 0.72) # 消息面板中部
|
||
click_y = int(h * 0.60) # 消息面板中部偏下
|
||
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)
|
||
|
||
# 滚动到底部(PostMessage 安全,不切焦点,确保新消息可见)
|
||
self._force_scroll_bottom(platform)
|
||
time.sleep(0.2)
|
||
|
||
# TIM/QQ NT: 安全键盘导航(无 Esc,不关窗口)
|
||
# 每步前校验目标窗口仍在焦点,防止 keybd_event 泄露到控制台
|
||
VK_CONTROL = 0x11
|
||
variant = self._app_variant.get(platform, "")
|
||
if variant in ("tim", "qq_nt"):
|
||
VK_RETURN = 0x0D
|
||
VK_END = 0x23
|
||
|
||
# Enter 激活当前对话
|
||
if self._fg_is_target(hwnd):
|
||
ctypes.windll.user32.keybd_event(VK_RETURN, 0, 0, 0)
|
||
time.sleep(0.05)
|
||
ctypes.windll.user32.keybd_event(VK_RETURN, 0, 2, 0)
|
||
time.sleep(0.3)
|
||
|
||
# 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)
|
||
ctypes.windll.user32.keybd_event(VK_END, 0, 2, 0)
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
|
||
time.sleep(0.3)
|
||
|
||
# Ctrl+A (全选) — 仅当目标窗口仍在焦点
|
||
VK_A = 0x41
|
||
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)
|
||
ctypes.windll.user32.keybd_event(VK_A, 0, 2, 0)
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
|
||
time.sleep(0.15)
|
||
# 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)
|
||
ctypes.windll.user32.keybd_event(VK_C, 0, 2, 0)
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
|
||
time.sleep(0.3)
|
||
|
||
wc.OpenClipboard()
|
||
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
|
||
content = wc.GetClipboardData(wc.CF_UNICODETEXT)
|
||
if content:
|
||
raw_lines = [l.strip() for l in content.strip().split('\n') if l.strip()]
|
||
self._raw_clipboard[platform] = content
|
||
wc.CloseClipboard()
|
||
|
||
logger.debug(f"[{platform}] 剪贴板读取 → {len(raw_lines)} lines")
|
||
|
||
# ── 过滤:只返回"像用户消息"的行 ──
|
||
sender_pattern = re.compile(r'^[\u4e00-\u9fff·A-Za-z0-9_\-]{1,10}$')
|
||
for line in raw_lines:
|
||
# 跳过:系统消息、纯时间、纯数字、无意义短行
|
||
if self._is_system_message(line):
|
||
continue
|
||
# 跳过:控制台/Python 垃圾(剪贴板截错窗口内容)
|
||
if self._is_ocr_console_garbage(line):
|
||
continue
|
||
# 跳过:单行发送者名(1-10汉字/字母/数字/下划线)
|
||
if sender_pattern.match(line):
|
||
continue
|
||
# 跳过:纯时间格式(连字符/斜杠分格日期)
|
||
if re.match(r'^\d{1,2}[::\s]\d{2}', line):
|
||
continue
|
||
if re.match(r'^\d{4}[/-]\d{1,2}', line):
|
||
continue
|
||
# 跳过:纯 emoji / 纯标点(短于 6 个字符且只有符号)
|
||
clean = re.sub('[~!@#$%^&*()_+\\-=\\[\\]{};:\\\'",.<>/?·!@#¥%……&*()—+。,、;:""\'\'【】{}「」『』《》【】 \\t\\n\\r\\u2000-\\u206f\\u2190-\\u2bff\\U0001F000-\\U0001FFFF]', '', line)
|
||
if len(clean) == 0 and len(line) <= 6:
|
||
continue
|
||
# 通过全部检查 → 保留
|
||
texts.append(line)
|
||
|
||
logger.debug(f"[{platform}] 剪贴板过滤后 → {len(texts)} 条用户消息")
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] 剪贴板失败: {e}")
|
||
finally:
|
||
if old is not None:
|
||
try:
|
||
wc.OpenClipboard()
|
||
wc.EmptyClipboard()
|
||
wc.SetClipboardText(old, wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
except Exception:
|
||
pass
|
||
|
||
return texts
|
||
|
||
def get_raw_clipboard(self, platform: str) -> str:
|
||
"""获取最近一次剪贴板读取的原始文本。"""
|
||
return self._raw_clipboard.get(platform, "")
|
||
|
||
@staticmethod
|
||
def _parse_last_sender(raw_text: str) -> Optional[str]:
|
||
"""从剪贴板原始文本中提取最后一条消息的发送者。
|
||
|
||
TIM/QQ 剪贴板格式通常是:
|
||
[发送者]
|
||
[时间]
|
||
[消息]
|
||
(空行)
|
||
[发送者]
|
||
[时间]
|
||
[消息]
|
||
"""
|
||
if not raw_text:
|
||
return None
|
||
blocks = re.split(r'\n\s*\n', raw_text.strip())
|
||
if not blocks:
|
||
return None
|
||
last_block = blocks[-1].strip()
|
||
if not last_block:
|
||
return None
|
||
lines = [l.strip() for l in last_block.split('\n') if l.strip()]
|
||
if not lines:
|
||
return None
|
||
candidate = lines[0]
|
||
# 排除时间格式
|
||
if re.match(r'^\d{1,2}[::]\d{2}', candidate):
|
||
return None
|
||
if re.match(r'^\d{4}[/-]\d{1,2}', candidate):
|
||
return None
|
||
if len(candidate) > 30:
|
||
return None
|
||
return candidate
|
||
|
||
def _search_contact(self, platform: str, contact: str) -> bool:
|
||
"""在 TIM/QQ 搜索框中搜索联系人并切换到对应会话。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd:
|
||
return False
|
||
try:
|
||
win = auto.ControlFromHandle(hwnd)
|
||
search = win.EditControl(Name="搜索", maxSearchSeconds=0.5)
|
||
if not search.Exists(maxSearchSeconds=0.3):
|
||
logger.warning(f"[{platform}] 找不到搜索框")
|
||
return False
|
||
|
||
# 恢复窗口并前置(失败则放弃搜索)
|
||
if win32gui.IsIconic(hwnd):
|
||
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
|
||
if not self._ensure_foreground(hwnd):
|
||
logger.warning(f"[{platform}] 无法前置窗口,放弃搜索联系人")
|
||
return False
|
||
time.sleep(0.2)
|
||
|
||
# SetFocus 到搜索框(不移动鼠标)
|
||
search.SetFocus()
|
||
time.sleep(0.2)
|
||
|
||
# 全选已有内容并输入联系人名
|
||
auto.SendKeys('{Ctrl}a')
|
||
time.sleep(0.1)
|
||
auto.SendKeys(contact)
|
||
time.sleep(0.5)
|
||
|
||
# Enter 打开第一个结果
|
||
auto.SendKeys('{Enter}')
|
||
time.sleep(0.5)
|
||
|
||
logger.info(f"[{platform}] 已搜索并切换到联系人: {contact}")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"[{platform}] 搜索联系人失败: {e}")
|
||
return False
|
||
|
||
# ── SendInput 键盘模拟 ─────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _key_down(vk: int):
|
||
"""SendInput KEYBDINPUT keydown."""
|
||
ctypes.windll.user32.keybd_event(vk, 0, 0, 0)
|
||
|
||
@staticmethod
|
||
def _key_up(vk: int):
|
||
"""SendInput KEYBDINPUT keyup (KEYEVENTF_KEYUP=2)."""
|
||
ctypes.windll.user32.keybd_event(vk, 0, 2, 0)
|
||
|
||
@staticmethod
|
||
def _send_key_press(vk: int, delay: float = 0.05):
|
||
"""SendInput single key press (down + up)."""
|
||
ctypes.windll.user32.keybd_event(vk, 0, 0, 0)
|
||
time.sleep(delay)
|
||
ctypes.windll.user32.keybd_event(vk, 0, 2, 0)
|
||
|
||
@staticmethod
|
||
def _send_ctrl_combo(vk: int):
|
||
"""Send Ctrl+<key>: Ctrl down → key down → key up → Ctrl up."""
|
||
VK_CONTROL = 0x11
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
|
||
ctypes.windll.user32.keybd_event(vk, 0, 0, 0)
|
||
ctypes.windll.user32.keybd_event(vk, 0, 2, 0)
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
|
||
|
||
@staticmethod
|
||
def _post_key(hwnd, vk, delay=0.05):
|
||
"""PostMessage 键盘到指定窗口(不全局,安全)。"""
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, vk, 0)
|
||
time.sleep(delay)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, vk, 0)
|
||
|
||
@staticmethod
|
||
def _post_ctrl_combo(hwnd, vk, delay=0.05):
|
||
"""PostMessage Ctrl+组合键到指定窗口(不泄露到控制台)。"""
|
||
VK_CONTROL = 0x11
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, VK_CONTROL, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, vk, 0)
|
||
time.sleep(delay)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, vk, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, VK_CONTROL, 0)
|
||
|
||
def _fg_is_target(self, hwnd) -> bool:
|
||
"""校验目标窗口仍在焦点,防止 keybd_event 泄露到控制台。"""
|
||
try:
|
||
fg = win32gui.GetForegroundWindow()
|
||
root = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
|
||
return fg == root or (fg and win32gui.IsChild(root, fg))
|
||
except Exception:
|
||
return False
|
||
|
||
@staticmethod
|
||
def _is_system_message(text: str) -> bool:
|
||
"""过滤机器人/系统消息,不作为用户消息处理。"""
|
||
if not text:
|
||
return True
|
||
# 系统前缀/标记
|
||
system_markers = [
|
||
"✅", "❌", "📝", "🔍", "📄", "➡", "♡", "✿",
|
||
"[WECHAT]", "[QQ]", "[wechat]", "[qq]",
|
||
]
|
||
for m in system_markers:
|
||
if text.startswith(m):
|
||
return True
|
||
# ⭐ QQ NT 界面噪声文字(按钮/提示/碎片,非用户消息)
|
||
qq_ui_noise = [
|
||
"查看更多", "可查看在其他设备产生的聊天记录",
|
||
"收藏", "转发", "多选", "引用", "回复",
|
||
"选择会话开始聊天",
|
||
]
|
||
for n in qq_ui_noise:
|
||
if text == n:
|
||
return True
|
||
# 系统事件模板 — 注意:用 startswith 而非 in,避免混合文本(OCR 多行合一)误杀
|
||
system_patterns = [
|
||
"已自动回复", "知识库中未找到", "找到相关结果",
|
||
"监控已启动", "监控已暂停", "设置已保存",
|
||
"🤖", "找不到联系人", "当前会话回复",
|
||
"自动回复触发", "匹配度",
|
||
# KB 文档内容格式(机器人回复格式),OCR 可能把用户消息+KB 回复混成一行
|
||
# 只有全行以"答:"开头才过滤,内含"答:"的混合文本不过滤
|
||
"答:",
|
||
# 纯系统命令反馈
|
||
"PS ", "❯ ", "$ ",
|
||
]
|
||
for p in system_patterns:
|
||
if text.startswith(p):
|
||
return True
|
||
# 纯时间格式(不含消息内容)
|
||
if re.match(r'^\d{1,2}:\d{2}', text):
|
||
return True
|
||
return False
|
||
|
||
def _paste_and_send(self, platform: str, text: str) -> bool:
|
||
"""粘贴文本到输入框并发送(剪贴板 + SendInput)。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd:
|
||
return False
|
||
|
||
import win32clipboard as wc
|
||
|
||
# 保存剪贴板
|
||
old = None
|
||
try:
|
||
wc.OpenClipboard()
|
||
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
|
||
old = wc.GetClipboardData(wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
# 设置答案到剪贴板
|
||
wc.OpenClipboard()
|
||
wc.EmptyClipboard()
|
||
wc.SetClipboardText(text, wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
|
||
# ── 更激进的窗口唤醒 ──
|
||
# 1) 允许任意进程 SetForegroundWindow(移除系统限制)
|
||
try:
|
||
ctypes.windll.user32.AllowSetForegroundWindow(-1) # ASFW_ANY
|
||
except Exception:
|
||
pass
|
||
|
||
# 2) 恢复/显示窗口
|
||
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 not win32gui.IsWindowVisible(root_hwnd):
|
||
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
|
||
time.sleep(0.3)
|
||
|
||
# 3) 试图 SetForegroundWindow + Alt 键锁(让当前线程获得用户输入资格)
|
||
win32gui.SetForegroundWindow(root_hwnd)
|
||
VK_MENU = 0x12
|
||
KEYEVENTF_KEYUP = 0x0002
|
||
ctypes.windll.user32.keybd_event(VK_MENU, 0, 0, 0) # Alt down
|
||
ctypes.windll.user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0) # Alt up
|
||
time.sleep(0.2)
|
||
|
||
# 4) 再 SetForegroundWindow + SwitchToThisWindow
|
||
win32gui.SetForegroundWindow(root_hwnd)
|
||
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
|
||
time.sleep(0.3)
|
||
|
||
# 5) 验证 foreground — 不成功则用 SendMessage 兜底
|
||
fg_now = win32gui.GetForegroundWindow()
|
||
fg_ok = (fg_now == root_hwnd) or (fg_now and win32gui.IsChild(root_hwnd, fg_now))
|
||
if not fg_ok:
|
||
logger.warning(f"[{platform}] 前台唤醒失败(fg={fg_now}, target={root_hwnd}),SendMessage 兜底")
|
||
# SendMessage 方式:直接发消息给窗口,不依赖前台
|
||
rect = win32gui.GetWindowRect(hwnd)
|
||
if rect:
|
||
left, top, right, bottom = rect
|
||
w = right - left
|
||
h = bottom - top
|
||
# QQ NT 输入框在右侧消息面板底部
|
||
click_x = int(w * 0.72)
|
||
click_y = int(h * 0.92)
|
||
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.1)
|
||
# Ctrl+A (全选)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, 0x41, 0 | (1 << 29)) # Ctrl 修饰
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, 0x41, 0)
|
||
time.sleep(0.05)
|
||
# Ctrl+V (粘贴)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, 0x56, 0 | (1 << 29))
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, 0x56, 0)
|
||
time.sleep(0.1)
|
||
# Enter (发送)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, 0x0D, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, 0x0D, 0)
|
||
time.sleep(0.5)
|
||
logger.info(f"[{platform}] 已回复(SendMessage): {text[:60]}")
|
||
else:
|
||
return False
|
||
else:
|
||
# ── 前台成功:PostMessage 点击输入框 + keybd_event 发送 ──
|
||
rect = win32gui.GetWindowRect(hwnd)
|
||
if rect:
|
||
left, top, right, bottom = rect
|
||
w = right - left
|
||
h = bottom - top
|
||
click_x = int(w * 0.72)
|
||
click_y = int(h * 0.92)
|
||
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)
|
||
|
||
# keybd_event 发送(窗口已在前台)
|
||
VK_A = 0x41
|
||
VK_V = 0x56
|
||
VK_BACK = 0x08
|
||
VK_RETURN = 0x0D
|
||
|
||
self._send_ctrl_combo(VK_A)
|
||
time.sleep(0.05)
|
||
self._key_down(VK_BACK)
|
||
self._key_up(VK_BACK)
|
||
time.sleep(0.05)
|
||
self._send_ctrl_combo(VK_V)
|
||
time.sleep(0.3)
|
||
self._key_down(VK_RETURN)
|
||
self._key_up(VK_RETURN)
|
||
time.sleep(0.5)
|
||
logger.info(f"[{platform}] 已回复: {text[:60]}")
|
||
|
||
# 记录回复文本到去重集
|
||
# 将回复文本加入 _replied_texts(永久集,永不裁切,必拦)和 seen
|
||
reply_text = text.strip()
|
||
self._replied_texts.add(reply_text)
|
||
self._seen_messages.add(reply_text)
|
||
# 添加换行符归一化变体(跨频道转发时会替换 \n → 空格)
|
||
if '\n' in reply_text:
|
||
self._replied_texts.add(reply_text.replace('\n', ' '))
|
||
self._replied_texts.add(reply_text.replace('\n', ' ').rstrip('.'))
|
||
self._seen_messages.add(reply_text.replace('\n', ' '))
|
||
self._seen_messages.add(reply_text.replace('\n', ' ').rstrip('.'))
|
||
for part in reply_text.split('\n'):
|
||
part = part.strip()
|
||
if part and len(part) > 3:
|
||
self._replied_texts.add(part)
|
||
self._seen_messages.add(part)
|
||
self._last_reply_time[platform] = time.time()
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"[{platform}] 回复失败: {e}\n{traceback.format_exc()}")
|
||
return False
|
||
finally:
|
||
# 恢复原始剪贴板
|
||
if old is not None:
|
||
try:
|
||
wc.OpenClipboard()
|
||
wc.EmptyClipboard()
|
||
wc.SetClipboardText(old, wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 核心:读消息 ──────────────────────────────────────────
|
||
|
||
def _read_messages(self, window, platform: str = "") -> list:
|
||
"""每轮重新定位 ListControl。"""
|
||
if platform == "qq":
|
||
# QQ NT: OCR 读屏优先(零依赖,不切焦点,不碰剪贴板)
|
||
texts = self._ocr_read_qq()
|
||
if texts:
|
||
return texts
|
||
|
||
# OCR 空 → 剪贴板兜底(窗口置前→滚底→全选→复制,内部自带重试)
|
||
texts = self._read_via_clipboard_once(platform)
|
||
if texts and len(texts) > 5:
|
||
return texts
|
||
|
||
# 剪贴板也空 → UIA 最后兜底(可能返回陈旧数据)
|
||
fresh_win = self._find_qq_window_fresh()
|
||
if fresh_win:
|
||
texts = self._read_message_list(fresh_win)
|
||
if texts:
|
||
return texts
|
||
texts = self._read_conversation_list(fresh_win)
|
||
if texts:
|
||
return texts
|
||
try:
|
||
rect = fresh_win.BoundingRectangle
|
||
boundary = rect.left + rect.width() * 0.40
|
||
for lst in self._list_controls(fresh_win):
|
||
try:
|
||
cr = lst.BoundingRectangle
|
||
if cr.left >= boundary:
|
||
texts = self._extract_last_line(lst)
|
||
if texts:
|
||
logger.debug(f"[S-C] ListControl(x={cr.left}) → {len(texts)} items")
|
||
return texts
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
try:
|
||
texts = self._collect_filtered_texts(fresh_win, "qq", max_depth=5)
|
||
if texts:
|
||
logger.debug(f"[S-D] 全窗文本 → {len(texts)} items")
|
||
return texts
|
||
except Exception:
|
||
pass
|
||
|
||
# Win32 兜底
|
||
texts = self._read_win32_texts(platform)
|
||
if texts:
|
||
return texts
|
||
return []
|
||
|
||
# 微信: 优先读"消息"(ScrollPattern 滚底后实时)
|
||
texts = self._read_message_list(window)
|
||
if texts:
|
||
return texts
|
||
# 兜底:会话列表
|
||
texts = self._read_conversation_list(window)
|
||
if texts:
|
||
return texts
|
||
try:
|
||
rect = window.BoundingRectangle
|
||
boundary = rect.left + rect.width() * 0.40
|
||
for lst in self._list_controls(window):
|
||
try:
|
||
cr = lst.BoundingRectangle
|
||
if cr.left >= boundary:
|
||
texts = self._extract_last_line(lst)
|
||
if texts:
|
||
logger.debug(f"[S-C] ListControl(x={cr.left}) → {len(texts)} items")
|
||
return texts
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
try:
|
||
texts = self._collect_filtered_texts(window, "wechat", max_depth=5)
|
||
if texts:
|
||
logger.debug(f"[S-D] 全窗文本 → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
||
return texts
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
def _read_message_list(self, window) -> list:
|
||
for name in ["消息", "Message"]:
|
||
try:
|
||
lst = window.ListControl(Name=name)
|
||
if lst.Exists(maxSearchSeconds=0.2):
|
||
self._scroll_to_bottom(lst)
|
||
texts = self._extract_last_line(lst)
|
||
if texts:
|
||
logger.debug(f"[S-A] ListControl({name}) → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
||
return texts
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
def _read_conversation_list(self, window) -> list:
|
||
for name in ["会话", "聊天", "Chat"]:
|
||
try:
|
||
lst = window.ListControl(Name=name)
|
||
if lst.Exists(maxSearchSeconds=0.2):
|
||
self._scroll_to_bottom(lst)
|
||
texts = self._extract_second_line(lst)
|
||
if texts:
|
||
logger.debug(f"[S-B] 会话({name}) → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
||
return texts
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
def _scroll_to_bottom(self, lst):
|
||
"""滚动 ListControl 到底部,触发应用加载新消息。"""
|
||
try:
|
||
scroll = lst.GetScrollPattern()
|
||
if scroll:
|
||
scroll.SetScrollPercent(100, 0)
|
||
except Exception:
|
||
pass
|
||
|
||
@staticmethod
|
||
def _extract_last_line(lst) -> list:
|
||
texts = []
|
||
try:
|
||
for item in lst.GetChildren():
|
||
try:
|
||
name = item.Name
|
||
if name and name.strip():
|
||
lines = [l.strip() for l in name.split("\n") if l.strip()]
|
||
if lines:
|
||
texts.append(lines[-1])
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return texts
|
||
|
||
@staticmethod
|
||
def _extract_deep_text(lst) -> list:
|
||
"""深度递归提取 ListControl 内所有控件的 Name 文本(最多3层)。"""
|
||
texts = []
|
||
|
||
def walk(ctrl, depth):
|
||
if depth > 3:
|
||
return
|
||
try:
|
||
name = ctrl.Name
|
||
if name and name.strip():
|
||
texts.append(name.strip())
|
||
except Exception:
|
||
pass
|
||
try:
|
||
for child in ctrl.GetChildren():
|
||
walk(child, depth + 1)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
for item in lst.GetChildren():
|
||
walk(item, 0)
|
||
except Exception:
|
||
pass
|
||
return texts
|
||
|
||
@staticmethod
|
||
def _extract_second_line(lst) -> list:
|
||
texts = []
|
||
try:
|
||
for item in lst.GetChildren():
|
||
try:
|
||
name = item.Name
|
||
if name and name.strip():
|
||
lines = [l.strip() for l in name.split("\n") if l.strip()]
|
||
if len(lines) >= 2:
|
||
texts.append(lines[1])
|
||
elif lines:
|
||
texts.append(lines[-1])
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return texts
|
||
|
||
@staticmethod
|
||
def _list_controls(window):
|
||
results = []
|
||
try:
|
||
for ctrl in window.GetChildren():
|
||
results.extend(_walk_list_controls(ctrl, 0))
|
||
except Exception:
|
||
pass
|
||
return results
|
||
|
||
@staticmethod
|
||
def _collect_filtered_texts(window, platform: str = "", max_depth=14) -> list:
|
||
"""深度遍历 + 位置过滤(排除搜索框/标题栏区域)。"""
|
||
items = [] # [(text, rect_top, rect_left, depth)]
|
||
|
||
def walk(ctrl, depth):
|
||
if depth > max_depth:
|
||
return
|
||
try:
|
||
cr = ctrl.BoundingRectangle
|
||
if cr.width() < 20 or cr.height() < 20:
|
||
return
|
||
except Exception:
|
||
return
|
||
try:
|
||
name = ctrl.Name
|
||
if name and name.strip():
|
||
t = name.strip()
|
||
if len(t) >= 2 and not t.isdigit():
|
||
items.append((t, cr.top, cr.left, depth))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
for child in ctrl.GetChildren():
|
||
walk(child, depth + 1)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
for child in window.GetChildren():
|
||
walk(child, 1)
|
||
except Exception:
|
||
pass
|
||
|
||
if not items:
|
||
return []
|
||
|
||
try:
|
||
w_rect = window.BoundingRectangle
|
||
w_width = w_rect.width()
|
||
w_height = w_rect.height()
|
||
except Exception:
|
||
w_width = 1390
|
||
w_height = 953
|
||
|
||
if platform == "qq":
|
||
filtered = [
|
||
t for t, top, left, d in items
|
||
if top > w_height * 0.08
|
||
]
|
||
seen = set()
|
||
deduped = []
|
||
for t in filtered:
|
||
if t not in seen:
|
||
seen.add(t)
|
||
deduped.append(t)
|
||
return deduped
|
||
|
||
return [t for t, _, _, _ in items]
|
||
|
||
# ── 单次轮询 ─────────────────────────────────────────────
|
||
|
||
def _poll(self, platform: str):
|
||
# 自动回复发送中跳过轮询(防止窗口焦点被抢)
|
||
if self._sending_event.is_set():
|
||
return
|
||
|
||
# 冷却期:回复后 5 秒内跳过 poll,防止反馈回环
|
||
last_reply = self._last_reply_time.get(platform, 0.0)
|
||
if time.time() - last_reply < 5.0:
|
||
return
|
||
|
||
counter = self._poll_count.get(platform, 0) + 1
|
||
self._poll_count[platform] = counter
|
||
|
||
# 1. 确保窗口句柄
|
||
hwnd = self._ensure_hwnd(platform)
|
||
if not hwnd:
|
||
if counter % 10 == 0:
|
||
logger.debug(f"[{platform}] 窗口未找到")
|
||
return
|
||
|
||
# 2. 从 HWND 创建全新 Control(绕过缓存)
|
||
win = self._fresh_window(platform)
|
||
if not win:
|
||
logger.debug(f"[{platform}] 创建窗口控件失败")
|
||
return
|
||
|
||
# 3. 读消息
|
||
texts = self._read_messages(win, platform)
|
||
|
||
if not texts:
|
||
empty = self._empty_reads.get(platform, 0) + 1
|
||
self._empty_reads[platform] = empty
|
||
if counter % 5 == 0:
|
||
logger.debug(f"[{platform}] 空读 #{empty}")
|
||
if empty >= 5:
|
||
self._cached_hwnd.pop(platform, None)
|
||
self._cached_pid.pop(platform, None)
|
||
self._empty_reads[platform] = 0
|
||
logger.info(f"[{platform}] 连续空读,清窗口缓存重试")
|
||
return
|
||
|
||
self._empty_reads[platform] = 0
|
||
|
||
# 4. 用全局 _seen_messages 去重(跨平台、跨轮次防回环)
|
||
# 精确匹配 + 模糊匹配(OCR 噪声导致精确匹配失效)
|
||
seen = self._seen_messages
|
||
replied = self._replied_texts
|
||
new_items = []
|
||
for t in texts:
|
||
ts = t.strip()
|
||
if not ts:
|
||
continue
|
||
# _replied_texts 优先检查(永不裁切,100% 拦住自己的回复)
|
||
if ts in replied:
|
||
continue
|
||
if ts in seen:
|
||
continue
|
||
# 模糊匹配:OCR 输出每次微小差异(勖彐碎片、行首数字、尾部截断)
|
||
# 精确匹配命中不了 → 用 difflib 相似度去重
|
||
if self._fuzzy_duplicate(ts, seen, threshold=0.72):
|
||
logger.debug(f"[{platform}] 模糊重复跳过: {ts[:50]}")
|
||
continue
|
||
if self._is_system_message(ts):
|
||
continue
|
||
# 纯装饰符号(单字符非汉字非字母数字)跳过
|
||
if len(ts) == 1 and not re.match(r'[\u4e00-\u9fff\w]', ts):
|
||
continue
|
||
# 只有符号/空格/emoji 的非消息文本(<3 字符且长度等于标点去掉后长度)
|
||
clean = re.sub(r'[\s\u2000-\u206f\u2190-\u2bff\U0001F000-\U0001FFFF✿♡♥❤★☆]', '', ts)
|
||
if len(ts) <= 3 and len(clean) == 0:
|
||
continue
|
||
seen.add(ts)
|
||
new_items.append(ts)
|
||
# 不截断 _seen_messages — _replied_texts 已拦截回复回环,截断会误放历史消息
|
||
# 极端情况(>50万行)才手动清,几乎不会出现
|
||
|
||
# 单次 poll 最多处理 3 条新消息,防止系统消息海量涌入触发回环
|
||
MAX_NEW_ITEMS = 3
|
||
new_items = new_items[:MAX_NEW_ITEMS]
|
||
|
||
# 更新 last_texts 供其他逻辑参考(但去重不依赖它)
|
||
self._last_texts[platform] = list(texts)
|
||
|
||
if new_items:
|
||
# 有纯粹的新文本 → 逐一报告
|
||
sig = "||".join([t.strip() for t in texts if t.strip()])
|
||
self._last_sig[platform] = sig
|
||
self._stale_count[platform] = 0
|
||
for item in new_items:
|
||
self._last_msg[platform] = item
|
||
logger.info(f"[{platform}] 新消息: {item[:60]}")
|
||
if self._on_message:
|
||
self._on_message(platform, item)
|
||
else:
|
||
# 无新文本 → 用全签名判断是否内容变化(emoji 刷新等)
|
||
sig = "||".join([t.strip() for t in texts if t.strip()])
|
||
old_sig = self._last_sig.get(platform, "")
|
||
if sig != old_sig:
|
||
self._last_sig[platform] = sig
|
||
self._stale_count[platform] = 0
|
||
else:
|
||
# 相同签名 → 冻结计数
|
||
stale = self._stale_count.get(platform, 0) + 1
|
||
self._stale_count[platform] = stale
|
||
if platform == "qq":
|
||
if stale == 3:
|
||
logger.debug(f"[QQ] 冻结 #{stale},强制刷新")
|
||
self._force_uia_refresh(platform)
|
||
elif stale == 6:
|
||
logger.debug(f"[QQ] 冻结 #{stale},重建窗口引用")
|
||
self._cached_hwnd.pop(platform, None)
|
||
self._cached_pid.pop(platform, None)
|
||
else:
|
||
if stale == 5:
|
||
logger.debug(f"[{platform}] 冻结 #{stale},尝试强制刷新")
|
||
self._force_uia_refresh(platform)
|
||
elif stale == 10:
|
||
logger.debug(f"[{platform}] 冻结 #{stale},重建窗口引用")
|
||
self._cached_hwnd.pop(platform, None)
|
||
self._cached_pid.pop(platform, None)
|
||
|
||
|
||
def _clean_old_ocr_debug(dir_path: str, keep: int = 20):
|
||
"""清理 ocr_debug_*.png 文件,只保留最近 keep 张。"""
|
||
try:
|
||
files = [f for f in os.listdir(dir_path) if f.startswith("ocr_debug_") and f.endswith(".png")]
|
||
if len(files) <= keep:
|
||
return
|
||
files.sort() # 按字母序 = 时间序
|
||
for f in files[:-keep]:
|
||
os.remove(os.path.join(dir_path, f))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _walk_list_controls(ctrl, depth):
|
||
if depth > 6:
|
||
return []
|
||
results = []
|
||
try:
|
||
cname = ctrl.ControlTypeName
|
||
except Exception:
|
||
return results
|
||
if cname == "ListControl":
|
||
results.append(ctrl)
|
||
return results
|
||
try:
|
||
for child in ctrl.GetChildren():
|
||
results.extend(_walk_list_controls(child, depth + 1))
|
||
except Exception:
|
||
pass
|
||
return results
|