Files
msgbot/monitor.py
T

411 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""消息监控 — 全列表指纹对比 + 窗口冻结检测 + 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