修复QQ读不出/读旧数据/auto-reply错发平台

问题1: QQ读不出消息 — daemon线程SetForegroundWindow频繁失败
   -> _run_loop添加PeekMessage创建Windows消息队列
   -> _attach_set_foreground内部异常全部捕获,不抛给调用者

问题2: QQ读旧剪贴板数据 — PostMessage键盘 Electron不响应
   -> 移除前台跳过检查(降频已足够,20s一次不频繁)
   -> _click_message_area PostMessage->SendMessage(Electron响应同步消息)
   -> 移除_force_scroll_bottom(PostMessage对Electron无效)

问题3: WeChat消息错发给QQ — auto-reply daemon线程SetForegroundWindow失败
   -> auto_reply.on_kb_match不再直接调用_paste_and_send
   -> 改为get_reply_data返回{platform,answer,sender}给主线程
   -> gui.py添加_reply_queue + _flush_reply_queue(主线程after轮询)
   -> _execute_reply在主线程执行_search_contact和_paste_and_send
      (SetForegroundWindow从主线程调用永远可靠)
This commit is contained in:
youpaishidifu
2026-07-25 13:06:36 +08:00
parent 336347d577
commit a0e54805fd
3 changed files with 91 additions and 53 deletions
+30 -25
View File
@@ -1,8 +1,7 @@
"""自动回复 — 消息匹配知识库后自动发回答案"""
"""自动回复 — 消息匹配知识库后准备答案,由主线程发送"""
import logging
import threading
import time
logger = logging.getLogger("msgbot.autoreply")
@@ -34,22 +33,42 @@ class AutoReply:
def min_score(self) -> float:
return self._min_score
def on_kb_match(self, platform: str, msg: str, top_doc: dict, score: float):
"""知识库匹配命中时调用。线程安全。"""
def on_kb_match(self, platform: str, msg: str, top_doc: dict, score: float) -> str or None:
"""知识库匹配命中时调用。线程安全。
返回要发送的回答文本,返回 None 表示不发送。
"""
if not self._enabled:
return
return None
if score < self._min_score:
logger.debug(
f"[{platform}] 匹配度 {score:.2f} < 阈值 {self._min_score},跳过回复"
)
return
return None
with self._lock:
self._try_reply(platform, msg, top_doc, score)
return self._get_answer(platform, msg, top_doc, score)
def _try_reply(self, platform: str, msg: str, doc: dict, score: float):
def get_reply_data(self, platform: str, msg: str, top_doc: dict, score: float) -> dict or None:
"""返回 {platform, answer, sender} 供主线程发送。"""
answer = self.on_kb_match(platform, msg, top_doc, score)
if not answer:
return None
# 获取发送者(在同一线程提取,不涉及窗口操作)
raw = self.monitor.get_raw_clipboard(platform)
sender = self.monitor._parse_last_sender(raw)
# 只有发送者是有效姓名且不在消息内容中时才切会话
if sender and sender not in msg and score >= self._min_score + 0.05:
pass
else:
sender = None
return {"platform": platform, "answer": answer, "sender": sender}
def _get_answer(self, platform: str, msg: str, doc: dict, score: float) -> str or None:
"""提取答案 + 防重复检查。"""
answer = doc["content"].strip()
if not answer:
return
return None
# 截取过长内容
if len(answer) > 500:
@@ -60,7 +79,7 @@ class AutoReply:
replied_set = self._replied.setdefault(platform, set())
if fp in replied_set:
logger.debug(f"[{platform}] 重复命中,跳过: {msg[:40]}")
return
return None
replied_set.add(fp)
# 控制缓存上限
if len(replied_set) > 200:
@@ -70,18 +89,4 @@ class AutoReply:
f"[{platform}] 🎯 自动回复触发 | 匹配度 {score:.2f} | "
f"消息: {msg[:40]}"
)
# 1. 提取发送者
raw = self.monitor.get_raw_clipboard(platform)
sender = self.monitor._parse_last_sender(raw)
if sender and sender not in msg and score >= self._min_score + 0.05:
# 2. 搜索联系人并切换会话
logger.info(f"[{platform}] 定位联系人: {sender}")
self.monitor._search_contact(platform, sender)
time.sleep(0.3)
else:
logger.info(f"[{platform}] 当前会话直接回复")
# 3. 发送消息
self.monitor._paste_and_send(platform, answer)
return answer
+38 -3
View File
@@ -51,11 +51,16 @@ class MsgBotApp(ctk.CTk):
# 日志队列(线程安全)
self._log_queue: queue.Queue[str] = queue.Queue()
# 自动回复执行队列(转发到主线程,避免 daemon 线程 SetForegroundWindow 失败)
self._reply_queue: queue.Queue[dict] = queue.Queue()
self._build_ui()
# 定时从队列取日志刷新
self.after(200, self._flush_log)
# 定时处理自动回复队列(主线程才能可靠 SetForegroundWindow)
self.after(200, self._flush_reply_queue)
# 窗口关闭
self.protocol("WM_DELETE_WINDOW", self._on_close)
@@ -303,7 +308,7 @@ class MsgBotApp(ctk.CTk):
args=(platform, msg), daemon=True).start()
def _search_and_log(self, platform: str, msg: str):
"""搜索知识库并输出结果,匹配成功时触发自动回复"""
"""搜索知识库并输出结果,匹配成功时队列自动回复到主线程"""
results = self.kb.search(msg, top_k=3)
lines = [f"[{platform.upper()}] {msg}"]
if results:
@@ -318,8 +323,10 @@ class MsgBotApp(ctk.CTk):
lines.append(f" 📄 [{title}] (匹配度 {score:.2f})")
lines.append(f" {snippet}")
# 自动回复(最高分结果,阈值在 AutoReply 内部判断)
self.ar.on_kb_match(platform, msg, results[0][0], results[0][1])
# 自动回复:队列到主线程执行(daemon 线程不能可靠 SetForegroundWindow)
reply_data = self.ar.get_reply_data(platform, msg, results[0][0], results[0][1])
if reply_data:
self._reply_queue.put(reply_data)
else:
lines.append(" ❌ 知识库中未找到相关答案")
@@ -343,6 +350,34 @@ class MsgBotApp(ctk.CTk):
pass
self.after(200, self._flush_log)
def _flush_reply_queue(self):
"""从自动回复队列消费——主线程执行,SetForegroundWindow 可靠。"""
try:
while True:
data = self._reply_queue.get_nowait()
self._execute_reply(data)
except queue.Empty:
pass
self.after(200, self._flush_reply_queue)
def _execute_reply(self, data: dict):
"""主线程执行自动回复发送。"""
platform = data["platform"]
answer = data["answer"]
sender = data.get("sender")
if sender:
# 搜索联系人切换会话
found = self.monitor._search_contact(platform, sender)
if not found:
self._log(f"[{platform}] ⚠ 找不到联系人 {sender},当前会话回复")
# 发送(主线程 SetForegroundWindow 可靠)
ok = self.monitor._paste_and_send(platform, answer)
if ok:
self._log(f"[{platform}] 🤖 已自动回复: {answer[:60]}...")
else:
self._log(f"[{platform}] ⚠ 自动回复发送失败")
def _clear_log(self):
self._log_text.configure(state="normal")
self._log_text.delete("1.0", "end")
+23 -25
View File
@@ -64,7 +64,8 @@ class MessageMonitor:
return
self._running = True
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="monitor")
self._thread.start()
# 创建 Windows 消息队列(使 AttachThreadInput / SetForegroundWindow 在 daemon 线程生效)
ctypes.windll.user32.PeekMessage(None, 0, 0, 0, 1) # PM_NOREMOVE
logger.info("消息监控已启动")
def stop(self):
@@ -93,9 +94,9 @@ class MessageMonitor:
if self._wechat_enabled:
self._poll("wechat")
if self._qq_enabled:
# QQ 降频:每 3 周期 poll 一次,减少抢窗口次数
# QQ 降频:每 10 周期 poll 一次(~20s),减少抢窗口
self._qq_skip_counter += 1
if self._qq_skip_counter >= 3:
if self._qq_skip_counter >= 10:
self._qq_skip_counter = 0
self._poll("qq")
except Exception as e:
@@ -221,10 +222,10 @@ class MessageMonitor:
logger.debug(f"[{platform}] 滚动到底失败: {e}")
def _click_message_area(self, platform: str):
"""PostMessage 点击消息区(靠右)聚焦消息列表,不移动物理鼠标。
"""SendMessage 点击消息区(靠右)聚焦消息列表,不移动物理鼠标。
TIM 三栏布局: 联系人|会话列表|消息区 → 消息区在最右 ~12%。
用 PostMessage 发送点击消息,不跳光标。
用 SendMessage 同步发送(Electron 响应同步消息),PostMessage 异步 Electron 不处理。
"""
hwnd = self._cached_hwnd.get(platform)
if not hwnd:
@@ -242,10 +243,11 @@ class MessageMonitor:
client_x = int(width * 0.88)
client_y = int(height * 0.50)
lparam = win32api.MAKELONG(client_x, client_y)
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
# SendMessage 同步 — Electron 能正确处理
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
time.sleep(0.3)
logger.debug(f"[{platform}] PostMessage 点击 ({client_x},{client_y})")
logger.debug(f"[{platform}] SendMessage 点击 ({client_x},{client_y})")
except Exception as e:
logger.debug(f"[{platform}] 聚焦消息区失败: {e}")
@@ -257,12 +259,21 @@ class MessageMonitor:
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)
else:
return
# AttachThreadInput 失败 → 裸 SetForegroundWindow
try:
win32gui.SetForegroundWindow(hwnd)
except Exception:
pass
except Exception:
win32gui.SetForegroundWindow(hwnd)
try:
win32gui.SetForegroundWindow(hwnd)
except Exception:
pass
def _read_win32_texts(self, platform: str) -> list:
"""Win32 API 直接读窗口文本(绕过 UIA 缓存)。"""
@@ -497,21 +508,8 @@ class MessageMonitor:
def _read_messages(self, window, platform: str = "") -> list:
"""每轮重新定位 ListControl。"""
if platform == "qq":
# QQ NT/TIM Electron: PostMessage 键盘不识别组合键 → 字面 "a"/"c"
# 唯一可靠是 SetForegroundWindow + SendKeys(Ctrl+A/C)
self._force_scroll_bottom(platform)
# QQ 在前台 → 用户正在用,跳过读取(不抢焦点)
qq_hwnd = self._cached_hwnd.get("qq")
if qq_hwnd:
try:
fg = win32gui.GetForegroundWindow()
if fg == qq_hwnd:
logger.debug("[QQ] 前台窗口,跳过读")
return []
except Exception:
pass
# QQ NT/TIM Electron: UIA 不可遍历,唯一可靠是 SetForegroundWindow + SendKeys(Ctrl+A/C)
# 降频到 20s 一次,减少焦点切换次数;切回原前台由 sendkeys 后自动恢复
texts = self._read_via_clipboard(platform)
if texts:
return texts