411 lines
14 KiB
Python
411 lines
14 KiB
Python
"""消息监控 — 全列表指纹对比 + 窗口冻结检测 + ControlFromHandle 强制刷新"""
|
||
|
||
import time
|
||
import logging
|
||
import threading
|
||
import traceback
|
||
from typing import Optional, Callable, List
|
||
|
||
import uiautomation as auto
|
||
import win32gui
|
||
|
||
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
|
||
|
||
# 全列表签名(hash of "|".join(all texts))
|
||
self._last_sig: dict = {}
|
||
# 最新消息文本(供 GUI 展示)
|
||
self._last_msg: dict = {}
|
||
|
||
# 轮询计数
|
||
self._poll_count: dict = {}
|
||
|
||
# 窗口句柄缓存(存 HWND,不存 Control 引用)
|
||
self._cached_hwnd: dict = {}
|
||
|
||
# 连续相同签名计数(用于冻结检测)
|
||
self._stale_count: dict = {}
|
||
# 连续空读
|
||
self._empty_reads: dict = {}
|
||
|
||
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:
|
||
self._thread.join(timeout=3)
|
||
self._thread = None
|
||
self._cached_hwnd.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):
|
||
heartbeat = 0
|
||
while self._running:
|
||
try:
|
||
if self._wechat_enabled:
|
||
self._poll("wechat")
|
||
if self._qq_enabled:
|
||
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)
|
||
|
||
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
|
||
self._cached_hwnd[platform] = h
|
||
logger.info(f"[{platform}] 窗口已定位: {name} handle={h}")
|
||
return h
|
||
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
|
||
self._cached_hwnd[platform] = h
|
||
logger.info(f"[{platform}] 窗口已定位(名称): {name}")
|
||
return h
|
||
except Exception:
|
||
pass
|
||
# QQ NT → 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
|
||
self._cached_hwnd[platform] = h
|
||
logger.info(f"[{platform}] 窗口已定位(TXGuiFoundation): {child.Name}")
|
||
return h
|
||
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 _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:
|
||
"""每轮重新定位 ListControl。"""
|
||
if platform == "wechat":
|
||
texts = self._read_conversation_list(window)
|
||
if texts:
|
||
return texts
|
||
if platform == "qq":
|
||
texts = self._read_conversation_list(window)
|
||
if texts:
|
||
return texts
|
||
texts = self._read_message_list(window)
|
||
if texts:
|
||
return texts
|
||
|
||
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_all_texts(window, 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):
|
||
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):
|
||
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 []
|
||
|
||
@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_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_all_texts(window, max_depth=4):
|
||
texts = []
|
||
def walk(ctrl, depth):
|
||
if depth > max_depth:
|
||
return
|
||
try:
|
||
cr = ctrl.BoundingRectangle
|
||
if cr.width() < 20 or cr.height() < 20:
|
||
return
|
||
except Exception:
|
||
pass
|
||
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 child in window.GetChildren():
|
||
walk(child, 1)
|
||
except Exception:
|
||
pass
|
||
return texts
|
||
|
||
# ── 单次轮询 ─────────────────────────────────────────────
|
||
|
||
def _poll(self, platform: str):
|
||
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._empty_reads[platform] = 0
|
||
logger.info(f"[{platform}] 连续空读,清窗口缓存重试")
|
||
return
|
||
|
||
self._empty_reads[platform] = 0
|
||
|
||
# 4. 全列表签名比对
|
||
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
|
||
latest = texts[-1].strip()
|
||
if 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):
|
||
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
|