问题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从主线程调用永远可靠)
391 lines
15 KiB
Python
391 lines
15 KiB
Python
"""GUI — customtkinter 界面,开关 + 知识库路径 + 日志 """
|
||
|
||
import tkinter as tk
|
||
from tkinter import filedialog, messagebox
|
||
from typing import Optional
|
||
import logging
|
||
import queue
|
||
import threading
|
||
import os
|
||
|
||
import customtkinter as ctk
|
||
|
||
from config import load_config, save_config
|
||
from monitor import MessageMonitor
|
||
from knowledge_base import KnowledgeBase
|
||
from auto_reply import AutoReply
|
||
|
||
logger = logging.getLogger("msgbot.gui")
|
||
|
||
ctk.set_appearance_mode("System")
|
||
ctk.set_default_color_theme("green")
|
||
|
||
|
||
class MsgBotApp(ctk.CTk):
|
||
"""主窗口"""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.title("消息问答助手 v1.0")
|
||
self.geometry("720x560")
|
||
self.minsize(640, 480)
|
||
|
||
# 配置
|
||
self.cfg = load_config()
|
||
|
||
# 知识库
|
||
self.kb = KnowledgeBase(self.cfg.get("knowledge_base_path", ""))
|
||
|
||
# 监控器
|
||
self.monitor = MessageMonitor(on_message=self._on_new_message)
|
||
self.monitor.set_poll_interval(self.cfg.get("poll_interval_seconds", 2.0))
|
||
self.monitor.set_enabled_platforms(
|
||
wechat=self.cfg.get("wechat_enabled", True),
|
||
qq=self.cfg.get("qq_enabled", False),
|
||
)
|
||
|
||
# 自动回复
|
||
self.ar = AutoReply(self.monitor)
|
||
self.ar.set_enabled(self.cfg.get("auto_reply_enabled", False))
|
||
self.ar.set_min_score(self.cfg.get("auto_reply_min_score", 0.35))
|
||
|
||
# 日志队列(线程安全)
|
||
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)
|
||
|
||
# ── UI 构建 ──────────────────────────────
|
||
|
||
def _build_ui(self):
|
||
self.grid_columnconfigure(0, weight=1)
|
||
self.grid_rowconfigure(2, weight=1)
|
||
|
||
# ── 顶部:开关 + 状态 ──
|
||
header = ctk.CTkFrame(self)
|
||
header.grid(row=0, column=0, padx=12, pady=(12, 4), sticky="ew")
|
||
header.grid_columnconfigure(1, weight=1)
|
||
|
||
ctk.CTkLabel(header, text="🤖 消息问答助手", font=("Microsoft YaHei", 18, "bold")).grid(
|
||
row=0, column=0, padx=(8, 16), pady=8, sticky="w"
|
||
)
|
||
|
||
self._status_label = ctk.CTkLabel(header, text="⏸ 已暂停", font=("Microsoft YaHei", 13))
|
||
self._status_label.grid(row=0, column=1, padx=4, pady=8, sticky="w")
|
||
|
||
self._toggle_btn = ctk.CTkSwitch(
|
||
header,
|
||
text="",
|
||
command=self._toggle_monitor,
|
||
onvalue=True,
|
||
offvalue=False,
|
||
)
|
||
self._toggle_btn.grid(row=0, column=2, padx=(4, 8), pady=8, sticky="e")
|
||
ctk.CTkLabel(header, text="启用监控", font=("Microsoft YaHei", 12)).grid(
|
||
row=0, column=3, padx=(0, 12), pady=8, sticky="w"
|
||
)
|
||
|
||
# ── 设置区域 ──
|
||
settings = ctk.CTkFrame(self)
|
||
settings.grid(row=1, column=0, padx=12, pady=4, sticky="ew")
|
||
settings.grid_columnconfigure(1, weight=1)
|
||
|
||
row = 0
|
||
# 知识库路径
|
||
ctk.CTkLabel(settings, text="📂 知识库路径:", font=("Microsoft YaHei", 12)).grid(
|
||
row=row, column=0, padx=(8, 4), pady=6, sticky="w"
|
||
)
|
||
self._kb_path_var = ctk.StringVar(value=self.cfg.get("knowledge_base_path", ""))
|
||
self._kb_entry = ctk.CTkEntry(
|
||
settings, textvariable=self._kb_path_var, placeholder_text="选择知识库文件夹(.md / .txt)"
|
||
)
|
||
self._kb_entry.grid(row=row, column=1, padx=4, pady=6, sticky="ew")
|
||
|
||
self._kb_browse_btn = ctk.CTkButton(
|
||
settings, text="浏览", width=64, command=self._browse_kb
|
||
)
|
||
self._kb_browse_btn.grid(row=row, column=2, padx=(4, 8), pady=6, sticky="e")
|
||
|
||
self._kb_status = ctk.CTkLabel(settings, text="未加载", font=("Microsoft YaHei", 11))
|
||
self._kb_status.grid(row=row, column=3, padx=4, pady=6, sticky="w")
|
||
row += 1
|
||
|
||
# 平台选择
|
||
ctk.CTkLabel(settings, text="📱 监控平台:", font=("Microsoft YaHei", 12)).grid(
|
||
row=row, column=0, padx=(8, 4), pady=6, sticky="w"
|
||
)
|
||
self._wx_var = ctk.BooleanVar(value=self.cfg.get("wechat_enabled", True))
|
||
self._wx_cb = ctk.CTkCheckBox(settings, text="微信", variable=self._wx_var,
|
||
command=self._save_plat_settings)
|
||
self._wx_cb.grid(row=row, column=1, padx=4, pady=6, sticky="w")
|
||
|
||
self._qq_var = ctk.BooleanVar(value=self.cfg.get("qq_enabled", False))
|
||
self._qq_cb = ctk.CTkCheckBox(settings, text="QQ", variable=self._qq_var,
|
||
command=self._save_plat_settings)
|
||
self._qq_cb.grid(row=row, column=1, padx=(90, 4), pady=6, sticky="w")
|
||
row += 1
|
||
|
||
# 轮询间隔
|
||
ctk.CTkLabel(settings, text="⏱ 轮询间隔(秒):", font=("Microsoft YaHei", 12)).grid(
|
||
row=row, column=0, padx=(8, 4), pady=6, sticky="w"
|
||
)
|
||
self._interval_var = ctk.DoubleVar(value=self.cfg.get("poll_interval_seconds", 2.0))
|
||
self._interval_slider = ctk.CTkSlider(
|
||
settings, from_=0.5, to=10.0, number_of_steps=19,
|
||
variable=self._interval_var, command=self._save_interval
|
||
)
|
||
self._interval_slider.grid(row=row, column=1, padx=4, pady=6, sticky="ew")
|
||
self._interval_label = ctk.CTkLabel(
|
||
settings, text=f"{self._interval_var.get():.1f}s", width=40
|
||
)
|
||
self._interval_label.grid(row=row, column=2, padx=(4, 8), pady=6, sticky="w")
|
||
# 应用按钮
|
||
self._apply_btn = ctk.CTkButton(
|
||
settings, text="应用设置", width=80, command=self._apply_settings
|
||
)
|
||
self._apply_btn.grid(row=row, column=3, padx=4, pady=6, sticky="e")
|
||
|
||
row += 1
|
||
# ── 自动回复 ──
|
||
ctk.CTkLabel(settings, text="🤖 自动回复:", font=("Microsoft YaHei", 12)).grid(
|
||
row=row, column=0, padx=(8, 4), pady=6, sticky="w"
|
||
)
|
||
ar_frame = ctk.CTkFrame(settings, fg_color="transparent")
|
||
ar_frame.grid(row=row, column=1, columnspan=2, padx=4, pady=6, sticky="ew")
|
||
ar_frame.grid_columnconfigure(2, weight=1)
|
||
|
||
self._ar_var = ctk.BooleanVar(value=self.cfg.get("auto_reply_enabled", False))
|
||
self._ar_toggle = ctk.CTkSwitch(
|
||
ar_frame, text="", variable=self._ar_var,
|
||
command=self._save_ar_settings,
|
||
onvalue=True, offvalue=False,
|
||
)
|
||
self._ar_toggle.grid(row=0, column=0, padx=(0, 6), pady=0, sticky="w")
|
||
|
||
ctk.CTkLabel(ar_frame, text="阈值:", font=("Microsoft YaHei", 12)).grid(
|
||
row=0, column=1, padx=(0, 4), pady=0, sticky="w"
|
||
)
|
||
self._ar_threshold_var = ctk.DoubleVar(
|
||
value=self.cfg.get("auto_reply_min_score", 0.35)
|
||
)
|
||
self._ar_threshold_slider = ctk.CTkSlider(
|
||
ar_frame, from_=0.10, to=0.90, number_of_steps=16,
|
||
variable=self._ar_threshold_var, command=self._save_ar_threshold,
|
||
)
|
||
self._ar_threshold_slider.grid(row=0, column=2, padx=2, pady=0, sticky="ew")
|
||
self._ar_threshold_label = ctk.CTkLabel(
|
||
ar_frame, text=f"{self._ar_threshold_var.get():.2f}", width=36
|
||
)
|
||
self._ar_threshold_label.grid(row=0, column=3, padx=(6, 0), pady=0, sticky="w")
|
||
|
||
# ── 日志区域 ──
|
||
log_frame = ctk.CTkFrame(self)
|
||
log_frame.grid(row=2, column=0, padx=12, pady=(4, 12), sticky="nsew")
|
||
log_frame.grid_rowconfigure(0, weight=1)
|
||
log_frame.grid_columnconfigure(0, weight=1)
|
||
|
||
self._log_text = ctk.CTkTextbox(log_frame, font=("Consolas", 11), wrap="word")
|
||
self._log_text.grid(row=0, column=0, padx=4, pady=4, sticky="nsew")
|
||
self._log_text.configure(state="disabled")
|
||
|
||
btn_frame = ctk.CTkFrame(log_frame, fg_color="transparent")
|
||
btn_frame.grid(row=1, column=0, pady=4, sticky="ew")
|
||
ctk.CTkButton(btn_frame, text="清空日志", width=80,
|
||
command=self._clear_log).pack(side="right", padx=4)
|
||
|
||
# 初始更新知识库状态
|
||
self._update_kb_status()
|
||
|
||
# ── 回调 ──────────────────────────────
|
||
|
||
def _toggle_monitor(self):
|
||
enabled = self._toggle_btn.get()
|
||
if enabled:
|
||
# 检查知识库是否已加载
|
||
if not self.kb.is_ready:
|
||
self._log("⚠ 知识库未加载,请先设置知识库路径")
|
||
self._toggle_btn.deselect()
|
||
return
|
||
self.monitor.start()
|
||
self._status_label.configure(text="▶ 运行中", text_color="#2ecc71")
|
||
self._log("✅ 消息监控已启动")
|
||
else:
|
||
self.monitor.stop()
|
||
self._status_label.configure(text="⏸ 已暂停")
|
||
self._log("⏸ 消息监控已暂停")
|
||
|
||
def _browse_kb(self):
|
||
path = filedialog.askdirectory(title="选择知识库文件夹")
|
||
if path:
|
||
self._kb_path_var.set(path)
|
||
self._apply_settings()
|
||
|
||
def _apply_settings(self):
|
||
# 保存知识库路径
|
||
path = self._kb_path_var.get().strip()
|
||
self.cfg["knowledge_base_path"] = path
|
||
self.kb.set_path(path)
|
||
self._update_kb_status()
|
||
|
||
# 平台
|
||
self.cfg["wechat_enabled"] = self._wx_var.get()
|
||
self.cfg["qq_enabled"] = self._qq_var.get()
|
||
|
||
# 自动回复
|
||
self.cfg["auto_reply_enabled"] = self._ar_var.get()
|
||
self.cfg["auto_reply_min_score"] = round(self._ar_threshold_var.get(), 2)
|
||
self.ar.set_enabled(self.cfg["auto_reply_enabled"])
|
||
self.ar.set_min_score(self.cfg["auto_reply_min_score"])
|
||
|
||
# 间隔
|
||
self.cfg["poll_interval_seconds"] = round(self._interval_var.get(), 1)
|
||
|
||
save_config(self.cfg)
|
||
|
||
# 更新监控器
|
||
self.monitor.set_poll_interval(self.cfg["poll_interval_seconds"])
|
||
self.monitor.set_enabled_platforms(
|
||
wechat=self.cfg["wechat_enabled"],
|
||
qq=self.cfg["qq_enabled"],
|
||
)
|
||
|
||
self._log(f"📝 设置已保存 | 知识库: {self.kb.get_doc_count()} 个文档 | "
|
||
f"轮询间隔: {self.cfg['poll_interval_seconds']}s")
|
||
|
||
def _save_plat_settings(self):
|
||
# 只保存平台选择,不触发完整应用
|
||
self.cfg["wechat_enabled"] = self._wx_var.get()
|
||
self.cfg["qq_enabled"] = self._qq_var.get()
|
||
save_config(self.cfg)
|
||
self.monitor.set_enabled_platforms(
|
||
wechat=self.cfg["wechat_enabled"],
|
||
qq=self.cfg["qq_enabled"],
|
||
)
|
||
|
||
def _save_interval(self, _=None):
|
||
self._interval_label.configure(text=f"{self._interval_var.get():.1f}s")
|
||
|
||
def _save_ar_settings(self):
|
||
"""自动回复开关即时保存"""
|
||
self.cfg["auto_reply_enabled"] = self._ar_var.get()
|
||
save_config(self.cfg)
|
||
self.ar.set_enabled(self.cfg["auto_reply_enabled"])
|
||
status = "开" if self._ar_var.get() else "关"
|
||
self._log(f"🤖 自动回复已切换为{status}")
|
||
|
||
def _save_ar_threshold(self, _=None):
|
||
val = round(self._ar_threshold_var.get(), 2)
|
||
self._ar_threshold_label.configure(text=f"{val:.2f}")
|
||
|
||
def _update_kb_status(self):
|
||
if self.kb.is_ready:
|
||
self._kb_status.configure(
|
||
text=f"✅ {self.kb.get_doc_count()} 个文档", text_color="#2ecc71"
|
||
)
|
||
else:
|
||
self._kb_status.configure(text="❌ 未加载", text_color="#e74c3c")
|
||
|
||
# ── 消息处理 ──
|
||
|
||
def _on_new_message(self, platform: str, msg: str):
|
||
"""收到新消息 -> 搜索知识库 -> 日志输出"""
|
||
if not self.kb.is_ready:
|
||
self._log_queue.put(f"[{platform.upper()}] {msg}")
|
||
self._log_queue.put(" ⚠ 知识库未加载,无法搜索答案")
|
||
return
|
||
|
||
# 在后台线程搜索
|
||
threading.Thread(target=self._search_and_log,
|
||
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:
|
||
answer_title = results[0][0]["title"]
|
||
answer_score = results[0][1]
|
||
lines.append(f" 🔍 找到 {len(results)} 条相关结果:")
|
||
for doc, score in results:
|
||
title = doc["title"]
|
||
snippet = doc["content"][:200].replace("\n", " ")
|
||
if len(snippet) > 200:
|
||
snippet = snippet[:197] + "..."
|
||
lines.append(f" 📄 [{title}] (匹配度 {score:.2f})")
|
||
lines.append(f" {snippet}")
|
||
|
||
# 自动回复:队列到主线程执行(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(" ❌ 知识库中未找到相关答案")
|
||
|
||
for line in lines:
|
||
self._log_queue.put(line)
|
||
|
||
def _log(self, text: str):
|
||
"""直接写日志(主线程)"""
|
||
self._log_queue.put(text)
|
||
|
||
def _flush_log(self):
|
||
"""从队列消费日志到文本框"""
|
||
try:
|
||
while True:
|
||
line = self._log_queue.get_nowait()
|
||
self._log_text.configure(state="normal")
|
||
self._log_text.insert("end", line + "\n")
|
||
self._log_text.see("end")
|
||
self._log_text.configure(state="disabled")
|
||
except queue.Empty:
|
||
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")
|
||
self._log_text.configure(state="disabled")
|
||
|
||
# ── 窗口关闭 ──
|
||
|
||
def _on_close(self):
|
||
self.monitor.stop()
|
||
self.destroy()
|