fix: 全列表签名指纹→检测对话排序变化+ControlFromHandle绕过UIA缓存+冻结5轮resize强制刷新
This commit is contained in:
+121
-92
@@ -1,21 +1,20 @@
|
|||||||
"""消息监控 — 每轮全路径重查 ListControl,彻底日志化"""
|
"""消息监控 — 全列表指纹对比 + 窗口冻结检测 + ControlFromHandle 强制刷新"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Optional, Callable
|
from typing import Optional, Callable, List
|
||||||
|
|
||||||
import uiautomation as auto
|
import uiautomation as auto
|
||||||
|
import win32gui
|
||||||
|
|
||||||
# ── 关闭 comtypes 的 DEBUG 洪流 ──────────────────────────────
|
|
||||||
logging.getLogger("comtypes").setLevel(logging.WARNING)
|
logging.getLogger("comtypes").setLevel(logging.WARNING)
|
||||||
|
|
||||||
logger = logging.getLogger("msgbot.monitor")
|
logger = logging.getLogger("msgbot.monitor")
|
||||||
|
|
||||||
|
|
||||||
class MessageMonitor:
|
class MessageMonitor:
|
||||||
"""多线程消息监控器 — 不缓存 ListControl,每轮直接搜"""
|
"""多线程消息监控器"""
|
||||||
|
|
||||||
def __init__(self, on_message: Optional[Callable[[str, str], None]] = None):
|
def __init__(self, on_message: Optional[Callable[[str, str], None]] = None):
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -25,14 +24,19 @@ class MessageMonitor:
|
|||||||
self._wechat_enabled = True
|
self._wechat_enabled = True
|
||||||
self._qq_enabled = False
|
self._qq_enabled = False
|
||||||
|
|
||||||
# 内容指纹
|
# 全列表签名(hash of "|".join(all texts))
|
||||||
|
self._last_sig: dict = {}
|
||||||
|
# 最新消息文本(供 GUI 展示)
|
||||||
self._last_msg: dict = {}
|
self._last_msg: dict = {}
|
||||||
|
|
||||||
# 窗口缓存(只缓窗口,不缓存 ListControl)
|
# 轮询计数
|
||||||
self._cached_window: dict = {}
|
|
||||||
|
|
||||||
# 计数器
|
|
||||||
self._poll_count: dict = {}
|
self._poll_count: dict = {}
|
||||||
|
|
||||||
|
# 窗口句柄缓存(存 HWND,不存 Control 引用)
|
||||||
|
self._cached_hwnd: dict = {}
|
||||||
|
|
||||||
|
# 连续相同签名计数(用于冻结检测)
|
||||||
|
self._stale_count: dict = {}
|
||||||
# 连续空读
|
# 连续空读
|
||||||
self._empty_reads: dict = {}
|
self._empty_reads: dict = {}
|
||||||
|
|
||||||
@@ -59,9 +63,11 @@ class MessageMonitor:
|
|||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=3)
|
self._thread.join(timeout=3)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._cached_window.clear()
|
self._cached_hwnd.clear()
|
||||||
self._empty_reads.clear()
|
self._last_sig.clear()
|
||||||
self._last_msg.clear()
|
self._last_msg.clear()
|
||||||
|
self._stale_count.clear()
|
||||||
|
self._empty_reads.clear()
|
||||||
self._poll_count.clear()
|
self._poll_count.clear()
|
||||||
logger.info("消息监控已停止")
|
logger.info("消息监控已停止")
|
||||||
|
|
||||||
@@ -87,85 +93,117 @@ class MessageMonitor:
|
|||||||
logger.info(f"[♡] 心跳 #{heartbeat}")
|
logger.info(f"[♡] 心跳 #{heartbeat}")
|
||||||
time.sleep(self._poll_interval)
|
time.sleep(self._poll_interval)
|
||||||
|
|
||||||
# ── 窗口缓存 ─────────────────────────────────────────────
|
# ── 窗口定位 ─────────────────────────────────────────────
|
||||||
|
|
||||||
def _ensure_window(self, platform: str) -> bool:
|
def _ensure_hwnd(self, platform: str) -> Optional[int]:
|
||||||
win = self._cached_window.get(platform)
|
"""查找窗口并返回 HWND(不缓存 Control)。"""
|
||||||
if win and win.Exists(maxSearchSeconds=0.1):
|
hwnd = self._cached_hwnd.get(platform)
|
||||||
return True
|
try:
|
||||||
|
if hwnd:
|
||||||
|
if win32gui.IsWindow(hwnd):
|
||||||
|
return hwnd
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
self._cached_window.pop(platform, None)
|
self._cached_hwnd.pop(platform, None)
|
||||||
|
|
||||||
if platform == "wechat":
|
if platform == "wechat":
|
||||||
names = ["微信", "WeChat"]
|
for name in ["微信", "WeChat"]:
|
||||||
for name in names:
|
|
||||||
try:
|
try:
|
||||||
w = auto.WindowControl(Name=name)
|
w = auto.WindowControl(Name=name)
|
||||||
if w.Exists(maxSearchSeconds=0.5):
|
if w.Exists(maxSearchSeconds=0.5):
|
||||||
rect = w.BoundingRectangle
|
rect = w.BoundingRectangle
|
||||||
if rect.width() > 200 and rect.height() > 100:
|
if rect.width() > 200 and rect.height() > 100:
|
||||||
self._cached_window[platform] = w
|
h = w.NativeWindowHandle
|
||||||
logger.info(f"[{platform}] 窗口已定位: {name}")
|
self._cached_hwnd[platform] = h
|
||||||
return True
|
logger.info(f"[{platform}] 窗口已定位: {name} handle={h}")
|
||||||
|
return h
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
elif platform == "qq":
|
elif platform == "qq":
|
||||||
# QQ NT 的窗口类名为 TXGuiFoundation,标题是账号名
|
|
||||||
# 先按标题搜,再按类名搜
|
|
||||||
for name in ["QQ", "TIM"]:
|
for name in ["QQ", "TIM"]:
|
||||||
try:
|
try:
|
||||||
w = auto.WindowControl(Name=name)
|
w = auto.WindowControl(Name=name)
|
||||||
if w.Exists(maxSearchSeconds=0.3):
|
if w.Exists(maxSearchSeconds=0.3):
|
||||||
rect = w.BoundingRectangle
|
rect = w.BoundingRectangle
|
||||||
if rect.width() > 200 and rect.height() > 100:
|
if rect.width() > 200 and rect.height() > 100:
|
||||||
self._cached_window[platform] = w
|
h = w.NativeWindowHandle
|
||||||
|
self._cached_hwnd[platform] = h
|
||||||
logger.info(f"[{platform}] 窗口已定位(名称): {name}")
|
logger.info(f"[{platform}] 窗口已定位(名称): {name}")
|
||||||
return True
|
return h
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# 兜底:按类名 TXGuiFoundation 找 QQ NT 窗口
|
# QQ NT → TXGuiFoundation 类名
|
||||||
try:
|
try:
|
||||||
root = auto.GetRootControl()
|
root = auto.GetRootControl()
|
||||||
for child in root.GetChildren():
|
for child in root.GetChildren():
|
||||||
try:
|
try:
|
||||||
cls = child.ClassName or ""
|
if (child.ClassName or "") == "TXGuiFoundation":
|
||||||
if cls == "TXGuiFoundation":
|
|
||||||
rect = child.BoundingRectangle
|
rect = child.BoundingRectangle
|
||||||
if rect.width() > 200 and rect.height() > 100:
|
if rect.width() > 200 and rect.height() > 100:
|
||||||
self._cached_window[platform] = child
|
h = child.NativeWindowHandle
|
||||||
logger.info(f"[{platform}] 窗口已定位(类名TXGuiFoundation): {child.Name}")
|
self._cached_hwnd[platform] = h
|
||||||
return True
|
logger.info(f"[{platform}] 窗口已定位(TXGuiFoundation): {child.Name}")
|
||||||
|
return h
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return False
|
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 _force_uia_refresh(self, platform: str):
|
||||||
|
"""强制窗口重绘以触发 UIA 更新。"""
|
||||||
|
hwnd = self._cached_hwnd.get(platform)
|
||||||
|
if not hwnd:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
rect = win32gui.GetWindowRect(hwnd)
|
||||||
|
# 缩小 1px 再恢复 → 强制重绘
|
||||||
|
w, h = rect[2] - rect[0], rect[3] - rect[1]
|
||||||
|
win32gui.SetWindowPos(hwnd, 0, rect[0], rect[1], w + 1, h, 0x0002 | 0x0004)
|
||||||
|
win32gui.SetWindowPos(hwnd, 0, rect[0], rect[1], w, h, 0x0002 | 0x0004)
|
||||||
|
logger.debug(f"[{platform}] UIA强制刷新 (resize 1px)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[{platform}] 强制刷新失败: {e}")
|
||||||
|
|
||||||
# ── 核心:读消息 ──────────────────────────────────────────
|
# ── 核心:读消息 ──────────────────────────────────────────
|
||||||
|
|
||||||
def _read_messages(self, window, platform: str = "") -> list:
|
def _read_messages(self, window, platform: str = "") -> list:
|
||||||
"""每轮重新定位 ListControl,不缓存。"""
|
"""每轮重新定位 ListControl。"""
|
||||||
# 微信:先读"会话"(左侧摘要,永远最新)
|
|
||||||
if platform == "wechat":
|
if platform == "wechat":
|
||||||
texts = self._read_conversation_list(window)
|
texts = self._read_conversation_list(window)
|
||||||
if texts:
|
if texts:
|
||||||
return texts
|
return texts
|
||||||
|
|
||||||
# QQ:先读"消息"(右侧详情列表)
|
|
||||||
if platform == "qq":
|
if platform == "qq":
|
||||||
|
texts = self._read_conversation_list(window)
|
||||||
|
if texts:
|
||||||
|
return texts
|
||||||
texts = self._read_message_list(window)
|
texts = self._read_message_list(window)
|
||||||
if texts:
|
if texts:
|
||||||
return texts
|
return texts
|
||||||
|
|
||||||
# 兜底:两个都试
|
|
||||||
texts = self._read_message_list(window)
|
texts = self._read_message_list(window)
|
||||||
if texts:
|
if texts:
|
||||||
return texts
|
return texts
|
||||||
texts = self._read_conversation_list(window)
|
texts = self._read_conversation_list(window)
|
||||||
if texts:
|
if texts:
|
||||||
return texts
|
return texts
|
||||||
|
|
||||||
# 策略 C: 任何右侧 ListControl
|
|
||||||
try:
|
try:
|
||||||
rect = window.BoundingRectangle
|
rect = window.BoundingRectangle
|
||||||
boundary = rect.left + rect.width() * 0.40
|
boundary = rect.left + rect.width() * 0.40
|
||||||
@@ -179,10 +217,8 @@ class MessageMonitor:
|
|||||||
return texts
|
return texts
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug(f"[S-C] 异常: {e}")
|
pass
|
||||||
|
|
||||||
# 策略 D: 全窗兜底
|
|
||||||
try:
|
try:
|
||||||
texts = self._collect_all_texts(window, max_depth=5)
|
texts = self._collect_all_texts(window, max_depth=5)
|
||||||
if texts:
|
if texts:
|
||||||
@@ -190,11 +226,9 @@ class MessageMonitor:
|
|||||||
return texts
|
return texts
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _read_message_list(self, window) -> list:
|
def _read_message_list(self, window) -> list:
|
||||||
"""读 ListControl '消息'(右侧详情列表)。"""
|
|
||||||
for name in ["消息", "Message"]:
|
for name in ["消息", "Message"]:
|
||||||
try:
|
try:
|
||||||
lst = window.ListControl(Name=name)
|
lst = window.ListControl(Name=name)
|
||||||
@@ -203,13 +237,11 @@ class MessageMonitor:
|
|||||||
if texts:
|
if texts:
|
||||||
logger.debug(f"[S-A] ListControl({name}) → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
logger.debug(f"[S-A] ListControl({name}) → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
||||||
return texts
|
return texts
|
||||||
logger.debug(f"[S-A] ListControl({name}) 存在但无文本")
|
except Exception:
|
||||||
except Exception as e:
|
pass
|
||||||
logger.debug(f"[S-A] ListControl({name}) 异常: {e}")
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _read_conversation_list(self, window) -> list:
|
def _read_conversation_list(self, window) -> list:
|
||||||
"""读 ListControl '会话'(左侧摘要,总是包含最新消息摘要)。"""
|
|
||||||
for name in ["会话", "聊天", "Chat"]:
|
for name in ["会话", "聊天", "Chat"]:
|
||||||
try:
|
try:
|
||||||
lst = window.ListControl(Name=name)
|
lst = window.ListControl(Name=name)
|
||||||
@@ -218,15 +250,12 @@ class MessageMonitor:
|
|||||||
if texts:
|
if texts:
|
||||||
logger.debug(f"[S-B] 会话({name}) → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
logger.debug(f"[S-B] 会话({name}) → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
||||||
return texts
|
return texts
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug(f"[S-B] ListControl({name}) 异常: {e}")
|
pass
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# ── 提取方法 ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_last_line(lst) -> list:
|
def _extract_last_line(lst) -> list:
|
||||||
"""从 ListItem Name 提取最后一行(消息详情列表用)。"""
|
|
||||||
texts = []
|
texts = []
|
||||||
try:
|
try:
|
||||||
for item in lst.GetChildren():
|
for item in lst.GetChildren():
|
||||||
@@ -244,7 +273,6 @@ class MessageMonitor:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_second_line(lst) -> list:
|
def _extract_second_line(lst) -> list:
|
||||||
"""从 ListItem Name 提取第二行(会话摘要列表用:格式为 名字\\n消息\\n时间)。"""
|
|
||||||
texts = []
|
texts = []
|
||||||
try:
|
try:
|
||||||
for item in lst.GetChildren():
|
for item in lst.GetChildren():
|
||||||
@@ -252,8 +280,6 @@ class MessageMonitor:
|
|||||||
name = item.Name
|
name = item.Name
|
||||||
if name and name.strip():
|
if name and name.strip():
|
||||||
lines = [l.strip() for l in name.split("\n") if l.strip()]
|
lines = [l.strip() for l in name.split("\n") if l.strip()]
|
||||||
# 会话列表格式:姓名、消息内容、时间、状态...
|
|
||||||
# 取索引 1(消息内容),不够两行时取最后一行
|
|
||||||
if len(lines) >= 2:
|
if len(lines) >= 2:
|
||||||
texts.append(lines[1])
|
texts.append(lines[1])
|
||||||
elif lines:
|
elif lines:
|
||||||
@@ -264,11 +290,8 @@ class MessageMonitor:
|
|||||||
pass
|
pass
|
||||||
return texts
|
return texts
|
||||||
|
|
||||||
# ── 枚举所有 ListControl ────────────────────────────────
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _list_controls(window):
|
def _list_controls(window):
|
||||||
"""返回窗口内所有 ListControl。"""
|
|
||||||
results = []
|
results = []
|
||||||
try:
|
try:
|
||||||
for ctrl in window.GetChildren():
|
for ctrl in window.GetChildren():
|
||||||
@@ -277,8 +300,6 @@ class MessageMonitor:
|
|||||||
pass
|
pass
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# ── 全窗遍历 ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _collect_all_texts(window, max_depth=4):
|
def _collect_all_texts(window, max_depth=4):
|
||||||
texts = []
|
texts = []
|
||||||
@@ -315,53 +336,62 @@ class MessageMonitor:
|
|||||||
counter = self._poll_count.get(platform, 0) + 1
|
counter = self._poll_count.get(platform, 0) + 1
|
||||||
self._poll_count[platform] = counter
|
self._poll_count[platform] = counter
|
||||||
|
|
||||||
# 窗口缓存
|
# 1. 确保窗口句柄
|
||||||
if not self._ensure_window(platform):
|
hwnd = self._ensure_hwnd(platform)
|
||||||
if counter % 20 == 0:
|
if not hwnd:
|
||||||
|
if counter % 10 == 0:
|
||||||
logger.debug(f"[{platform}] 窗口未找到")
|
logger.debug(f"[{platform}] 窗口未找到")
|
||||||
return
|
return
|
||||||
|
|
||||||
win = self._cached_window[platform]
|
# 2. 从 HWND 创建全新 Control(绕过缓存)
|
||||||
|
win = self._fresh_window(platform)
|
||||||
|
if not win:
|
||||||
|
logger.debug(f"[{platform}] 创建窗口控件失败")
|
||||||
|
return
|
||||||
|
|
||||||
# 强制刷新 UIA 控件树(微信/QQ 虚拟列表不自动同步新消息)
|
# 3. 读消息
|
||||||
try:
|
|
||||||
win.Refresh()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 读消息
|
|
||||||
texts = self._read_messages(win, platform)
|
texts = self._read_messages(win, platform)
|
||||||
|
|
||||||
if not texts:
|
if not texts:
|
||||||
empty = self._empty_reads.get(platform, 0) + 1
|
empty = self._empty_reads.get(platform, 0) + 1
|
||||||
self._empty_reads[platform] = empty
|
self._empty_reads[platform] = empty
|
||||||
if counter % 10 == 0:
|
if counter % 5 == 0:
|
||||||
logger.debug(f"[{platform}] 空读 #{empty}")
|
logger.debug(f"[{platform}] 空读 #{empty}")
|
||||||
if empty >= 5:
|
if empty >= 5:
|
||||||
self._cached_window.pop(platform, None)
|
self._cached_hwnd.pop(platform, None)
|
||||||
self._empty_reads[platform] = 0
|
self._empty_reads[platform] = 0
|
||||||
logger.info(f"[{platform}] 连续空读,清窗口缓存重试")
|
logger.info(f"[{platform}] 连续空读,清窗口缓存重试")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 有文本,清空读
|
|
||||||
self._empty_reads[platform] = 0
|
self._empty_reads[platform] = 0
|
||||||
|
|
||||||
latest = texts[-1].strip()
|
# 4. 全列表签名比对
|
||||||
if not latest:
|
sig = "||".join([t.strip() for t in texts if t.strip()])
|
||||||
return
|
old_sig = self._last_sig.get(platform, "")
|
||||||
|
|
||||||
old = self._last_msg.get(platform, "")
|
if sig != old_sig:
|
||||||
if latest != old:
|
self._last_sig[platform] = sig
|
||||||
self._last_msg[platform] = latest
|
self._stale_count[platform] = 0
|
||||||
logger.info(f"[{platform}] 新消息: {latest}")
|
latest = texts[-1].strip()
|
||||||
if self._on_message:
|
if latest:
|
||||||
self._on_message(platform, latest)
|
self._last_msg[platform] = latest
|
||||||
|
logger.info(f"[{platform}] 新消息: {latest[:60]}")
|
||||||
|
if self._on_message:
|
||||||
|
self._on_message(platform, latest)
|
||||||
|
else:
|
||||||
|
# 相同签名 → 冻结计数
|
||||||
|
stale = self._stale_count.get(platform, 0) + 1
|
||||||
|
self._stale_count[platform] = stale
|
||||||
|
# 连续 5 次相同 → 尝试强制刷新 UIA
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
# ── 模块级别辅助 ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _walk_list_controls(ctrl, depth):
|
def _walk_list_controls(ctrl, depth):
|
||||||
"""递归寻找 ListControl,深度限制。"""
|
|
||||||
if depth > 6:
|
if depth > 6:
|
||||||
return []
|
return []
|
||||||
results = []
|
results = []
|
||||||
@@ -369,10 +399,9 @@ def _walk_list_controls(ctrl, depth):
|
|||||||
cname = ctrl.ControlTypeName
|
cname = ctrl.ControlTypeName
|
||||||
except Exception:
|
except Exception:
|
||||||
return results
|
return results
|
||||||
# 用 ControlTypeName 判断,避免 isinstance 不可靠
|
|
||||||
if cname == "ListControl":
|
if cname == "ListControl":
|
||||||
results.append(ctrl)
|
results.append(ctrl)
|
||||||
return results # 不深入 ListControl 内部
|
return results
|
||||||
try:
|
try:
|
||||||
for child in ctrl.GetChildren():
|
for child in ctrl.GetChildren():
|
||||||
results.extend(_walk_list_controls(child, depth + 1))
|
results.extend(_walk_list_controls(child, depth + 1))
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ customtkinter>=5.2.2
|
|||||||
jieba>=0.42.1
|
jieba>=0.42.1
|
||||||
scikit-learn>=1.3.0
|
scikit-learn>=1.3.0
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
|
pywin32>=308
|
||||||
|
|||||||
Reference in New Issue
Block a user