Files
msgbot/monitor.py
T
youpaishidifu 4349b5c176 清理旧代码+修复TIM抓错搜索框
- 删除旧 _collect_all_texts(被 _collect_filtered_texts 替代)
- 删除 _read_message_list_deep(旧QQ路径,已废弃)
- 微信fallback改走 _collect_filtered_texts
- 添加 _click_message_area:剪贴板前先点击中右区域聚焦消息区
- TIM Electron 通过鼠标点击消息区再Ctrl+A→Ctrl+C
- 添加 win32api import(用于 SetCursorPos+mouse_event)
2026-07-24 23:34:20 +08:00

640 lines
23 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
import win32con
import win32api
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:
# 方式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 _click_message_area(self, platform: str):
"""点击窗口消息区(中右区域)聚焦消息列表,避免焦点在搜索框。
TIM Electron 消息区 UIA 不可遍历,剪贴板法会复制搜索框内容。
先点击消息区确保焦点在消息列表。
"""
hwnd = self._cached_hwnd.get(platform)
if not hwnd:
return
try:
rect = win32gui.GetWindowRect(hwnd)
if not rect:
return
left, top, right, bottom = rect
width = right - left
height = bottom - top
if width < 100 or height < 100:
return
# 消息区大致在中右区域
click_x = left + int(width * 0.65)
click_y = top + int(height * 0.45)
win32api.SetCursorPos((click_x, click_y))
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, click_x, click_y, 0, 0)
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, click_x, click_y, 0, 0)
time.sleep(0.3)
logger.debug(f"[{platform}] 已点击消息区 ({click_x},{click_y})")
except Exception as e:
logger.debug(f"[{platform}] 聚焦消息区失败: {e}")
def _read_win32_texts(self, platform: str) -> list:
"""Win32 API 直接读窗口文本(绕过 UIA 缓存)。"""
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()) >= 4:
texts.append(text.strip())
except Exception:
pass
return True
try:
main_text = win32gui.GetWindowText(hwnd)
if main_text and len(main_text.strip()) >= 4:
texts.append(main_text.strip())
win32gui.EnumChildWindows(hwnd, enum_callback, None)
except Exception:
pass
return texts
def _read_via_clipboard(self, platform: str) -> list:
"""剪贴板法:聚焦窗口→Ctrl+A→Ctrl+C→读剪贴板(适用 Electron 应用)。
稳妥可靠但会切焦点+清用户剪贴板(自动恢复原内容)。
"""
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 = []
try:
win32gui.SetForegroundWindow(hwnd)
time.sleep(0.3)
# Ctrl+A (全选)
auto.SendKeys('{Ctrl}a')
time.sleep(0.15)
# Ctrl+C (复制)
auto.SendKeys('{Ctrl}c')
time.sleep(0.3)
wc.OpenClipboard()
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
content = wc.GetClipboardData(wc.CF_UNICODETEXT)
if content:
texts = [l.strip() for l in content.strip().split('\n') if l.strip()]
wc.CloseClipboard()
logger.debug(f"[{platform}] 剪贴板读取 → {len(texts)} lines")
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 _read_messages(self, window, platform: str = "") -> list:
"""每轮重新定位 ListControl。"""
if platform == "qq":
# TIM/QQ NT: Electron 应用,UIA 树很深且消息区内容不可遍历
# 策略A: 深度 UIA 遍历 + 过滤搜索框区域(非侵入)
# 策略B: 剪贴板法(可靠但切焦点)
# 策略C: Win32 EnumChildWindows 回退
self._force_scroll_bottom(platform)
# A: 深度 UIA 遍历(depth=12),按位置过滤搜索框区域
texts = self._collect_filtered_texts(window, platform)
if texts:
logger.debug(f"[QQ] 深度遍历 → {len(texts)} items, last=\"{texts[-1][:50]}\"")
return texts
# B: 聚焦消息区 → 剪贴板法(Electron 最可靠)
self._click_message_area(platform)
texts = self._read_via_clipboard(platform)
if texts:
return texts
# C: Win32 回退
texts = self._read_win32_texts(platform)
if texts:
logger.debug(f"[QQ] Win32 窗口文本 → {len(texts)} items")
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):
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
if platform == "qq":
# QQ: UIA 冻结更快,更激进
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)
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)
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