添加自动回复功能: KB匹配命中后发答案回对应消息窗口

- monitor.py: 添加原始剪贴板存储 + _parse_last_sender提取发送者
  + _search_contact搜索框定位联系人 + _paste_and_send粘贴发送
- auto_reply.py(新): AutoReply编排类,含防重复/阈值过滤/线程锁
- gui.py: UI添加自动回复开关+阈值滑块,搜索命中回调触发回复
- config.py: 添加auto_reply_enabled/auto_reply_min_score配置项
This commit is contained in:
youpaishidifu
2026-07-25 11:01:02 +08:00
parent 877a5b2afb
commit 7111f84a6d
4 changed files with 304 additions and 2 deletions
+87
View File
@@ -0,0 +1,87 @@
"""自动回复 — 消息匹配知识库后自动发回答案"""
import logging
import threading
import time
logger = logging.getLogger("msgbot.autoreply")
class AutoReply:
"""自动回复控制器"""
def __init__(self, monitor):
self.monitor = monitor
self._enabled = False
self._min_score = 0.35
# 已回复的消息指纹(防重复)
self._replied: dict = {}
self._lock = threading.Lock()
def set_enabled(self, enabled: bool):
self._enabled = enabled
logger.info(f"自动回复 {'已开启' if enabled else '已关闭'}")
def set_min_score(self, score: float):
self._min_score = max(0.0, min(1.0, score))
@property
def enabled(self) -> bool:
return self._enabled
@property
def min_score(self) -> float:
return self._min_score
def on_kb_match(self, platform: str, msg: str, top_doc: dict, score: float):
"""知识库匹配命中时调用。线程安全。"""
if not self._enabled:
return
if score < self._min_score:
logger.debug(
f"[{platform}] 匹配度 {score:.2f} < 阈值 {self._min_score},跳过回复"
)
return
with self._lock:
self._try_reply(platform, msg, top_doc, score)
def _try_reply(self, platform: str, msg: str, doc: dict, score: float):
answer = doc["content"].strip()
if not answer:
return
# 截取过长内容
if len(answer) > 500:
answer = answer[:497] + "..."
# 防重复指纹
fp = hash(f"{platform}:{msg}:{answer[:80]}")
replied_set = self._replied.setdefault(platform, set())
if fp in replied_set:
logger.debug(f"[{platform}] 重复命中,跳过: {msg[:40]}")
return
replied_set.add(fp)
# 控制缓存上限
if len(replied_set) > 200:
self._replied[platform] = set(list(replied_set)[-100:])
logger.info(
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)
+2
View File
@@ -12,6 +12,8 @@ DEFAULT_CONFIG = {
"poll_interval_seconds": 2.0,
"wechat_enabled": True,
"qq_enabled": False,
"auto_reply_enabled": False,
"auto_reply_min_score": 0.35,
}
+63 -2
View File
@@ -13,6 +13,7 @@ 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")
@@ -43,6 +44,11 @@ class MsgBotApp(ctk.CTk):
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()
self._build_ui()
@@ -143,6 +149,39 @@ class MsgBotApp(ctk.CTk):
)
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")
@@ -196,6 +235,12 @@ class MsgBotApp(ctk.CTk):
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)
@@ -224,6 +269,18 @@ class MsgBotApp(ctk.CTk):
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(
@@ -246,19 +303,23 @@ 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:
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}")
# 自动回复(最高分结果,阈值在 AutoReply 内部判断)
self.ar.on_kb_match(platform, msg, results[0][0], results[0][1])
else:
lines.append(" ❌ 知识库中未找到相关答案")
+152
View File
@@ -10,6 +10,7 @@ import uiautomation as auto
import win32gui
import win32con
import win32api
import re
logging.getLogger("comtypes").setLevel(logging.WARNING)
logger = logging.getLogger("msgbot.monitor")
@@ -41,6 +42,8 @@ class MessageMonitor:
self._stale_count: dict = {}
# 连续空读
self._empty_reads: dict = {}
# 剪贴板原始文本(用于自动回复提取发送者)
self._raw_clipboard: dict = {}
def set_poll_interval(self, seconds: float):
self._poll_interval = max(0.5, seconds)
@@ -300,6 +303,7 @@ class MessageMonitor:
content = wc.GetClipboardData(wc.CF_UNICODETEXT)
if content:
texts = [l.strip() for l in content.strip().split('\n') if l.strip()]
self._raw_clipboard[platform] = content
wc.CloseClipboard()
logger.debug(f"[{platform}] 剪贴板读取 → {len(texts)} lines")
@@ -317,6 +321,154 @@ class MessageMonitor:
return texts
# ── 自动回复相关 ──────────────────────────────────────────
def get_raw_clipboard(self, platform: str) -> str:
"""获取最近一次剪贴板读取的原始文本。"""
return self._raw_clipboard.get(platform, "")
@staticmethod
def _parse_last_sender(raw_text: str) -> Optional[str]:
"""从剪贴板原始文本中提取最后一条消息的发送者。
TIM/QQ 剪贴板格式通常是:
[发送者]
[时间]
[消息]
(空行)
[发送者]
[时间]
[消息]
"""
if not raw_text:
return None
blocks = re.split(r'\n\s*\n', raw_text.strip())
if not blocks:
return None
last_block = blocks[-1].strip()
if not last_block:
return None
lines = [l.strip() for l in last_block.split('\n') if l.strip()]
if not lines:
return None
candidate = lines[0]
# 排除时间格式
if re.match(r'^\d{1,2}[::]\d{2}', candidate):
return None
if re.match(r'^\d{4}[/-]\d{1,2}', candidate):
return None
if len(candidate) > 30:
return None
return candidate
def _search_contact(self, platform: str, contact: str) -> bool:
"""在 TIM/QQ 搜索框中搜索联系人并切换到对应会话。"""
hwnd = self._cached_hwnd.get(platform)
if not hwnd:
return False
try:
win = auto.ControlFromHandle(hwnd)
search = win.EditControl(Name="搜索", maxSearchSeconds=0.5)
if not search.Exists(maxSearchSeconds=0.3):
logger.warning(f"[{platform}] 找不到搜索框")
return False
# 恢复窗口并前置
if win32gui.IsIconic(hwnd):
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
win32gui.SetForegroundWindow(hwnd)
time.sleep(0.3)
# SetFocus 到搜索框(不移动鼠标)
search.SetFocus()
time.sleep(0.2)
# 全选已有内容并输入联系人名
auto.SendKeys('{Ctrl}a')
time.sleep(0.1)
auto.SendKeys(contact)
time.sleep(0.5)
# Enter 打开第一个结果
auto.SendKeys('{Enter}')
time.sleep(0.5)
logger.info(f"[{platform}] 已搜索并切换到联系人: {contact}")
return True
except Exception as e:
logger.error(f"[{platform}] 搜索联系人失败: {e}")
return False
def _paste_and_send(self, platform: str, text: str) -> bool:
"""粘贴文本到输入框并发送(剪贴板 + Ctrl+V + Enter)。"""
hwnd = self._cached_hwnd.get(platform)
if not hwnd:
return False
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
try:
# 设置答案到剪贴板
wc.OpenClipboard()
wc.EmptyClipboard()
wc.SetClipboardText(text, wc.CF_UNICODETEXT)
wc.CloseClipboard()
# 窗口前置
if win32gui.IsIconic(hwnd):
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
win32gui.SetForegroundWindow(hwnd)
time.sleep(0.3)
# PostMessage 点击输入框区域(底部)— 不移动物理鼠标
rect = win32gui.GetWindowRect(hwnd)
if rect:
left, top, right, bottom = rect
w = right - left
h = bottom - top
click_x = int(w * 0.50)
click_y = int(h * 0.92)
lparam = win32api.MAKELONG(click_x, click_y)
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
time.sleep(0.2)
# 清空输入框 + 粘贴 + 发送
auto.SendKeys('{Ctrl}a')
time.sleep(0.05)
auto.SendKeys('{Backspace}')
time.sleep(0.05)
auto.SendKeys('{Ctrl}v')
time.sleep(0.3)
auto.SendKeys('{Enter}')
time.sleep(0.5)
logger.info(f"[{platform}] 已回复: {text[:60]}")
return True
except Exception as e:
logger.error(f"[{platform}] 回复失败: {e}")
return False
finally:
# 恢复原始剪贴板
if old is not None:
try:
wc.OpenClipboard()
wc.EmptyClipboard()
wc.SetClipboardText(old, wc.CF_UNICODETEXT)
wc.CloseClipboard()
except Exception:
pass
# ── 核心:读消息 ──────────────────────────────────────────
def _read_messages(self, window, platform: str = "") -> list: