添加自动回复功能: 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:
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user