feat: 微信自动化客服(wechatauto-replica) 干净历史导入 - AI 自动回复/语音收发/朋友圈发布

This commit is contained in:
2026-09-16 10:12:49 +08:00
commit c3b98f4c4e
69 changed files with 19828 additions and 0 deletions
+491
View File
@@ -0,0 +1,491 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""微信智能客服引擎 v3wechatauto-replica
在 v2 固定话术自动回复基础上升级为「大模型上下文对话 + 本机知识库」:
1. 每个客户(username)维护独立会话历史,持久化到磁盘,跨重启不丢,
按「最近 N 轮 / 最近 N 秒」窗口取上下文喂给大模型。
2. 收到消息时检索本机 Obsidian 知识库(字符 n-gram 相似度),把最相关的
笔记片段注入 system prompt,让回复「结合知识库」。
3. 通过 OpenAI 兼容接口调用大模型生成自然回复(默认 DeepSeek,可换
智谱/Kimi/通义等,只要填 base_url + model + api_key)。
4. 无 api_key 时自动降级:关键词规则 → 知识库片段拼接 → 兜底话术,
保证引擎不因缺 key 而停摆。
用法(项目目录下,独立 venv):
.venv\\Scripts\\python.exe wechat_ai_reply.py
前置:微信 4.x 已登录、桌面未锁屏、管理员权限(提密钥)。
配置:改 ai_config.json(重点是 llm.api_key)。
"""
import json
import os
import re
import sys
import time
import threading
import urllib.request
import urllib.error
import msvcrt
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto.db import WeChatDB, Listener
from wechatauto.guia import WeChatGUI
import voice2text
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "ai_config.json")
WATERMARK_PATH = os.path.join(BASE_DIR, "data", "watermark.json")
def load_watermark() -> dict:
"""加载监听水位(username -> 已推送的最大 sort_seq),重启不重复推送、不重复回复。"""
try:
if os.path.exists(WATERMARK_PATH):
data = json.loads(open(WATERMARK_PATH, "r", encoding="utf-8").read())
if isinstance(data, dict):
return data
except (OSError, ValueError):
pass
return {}
def save_watermark(wm: dict) -> None:
"""把监听水位落盘。"""
try:
os.makedirs(os.path.dirname(WATERMARK_PATH), exist_ok=True)
open(WATERMARK_PATH, "w", encoding="utf-8").write(
json.dumps(wm, ensure_ascii=False)
)
except OSError:
pass
def load_config() -> dict:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
CFG = load_config()
# ==================== 知识库检索(本地 n-gram 相似度) ====================
class KnowledgeBase:
"""读本机 Obsidian vault 的 .md 笔记,按字符 n-gram 重叠度检索。
与 Unai 本机 vault 的 hashed n-gram 检索思路一致:靠字符重叠而非语义。
排除 .obsidian 等元数据目录,只读已发布的分类笔记。
"""
def __init__(self, vault_dir: str):
self.vault_dir = vault_dir
self._notes = [] # [(title, path, text)]
self._load()
def _load(self):
if not self.vault_dir or not os.path.isdir(self.vault_dir):
print(f"[KB] 知识库目录不存在: {self.vault_dir}", flush=True)
return
for root, dirs, files in os.walk(self.vault_dir):
dirs[:] = [d for d in dirs if d not in (".obsidian", ".git")]
for name in files:
if not name.endswith(".md"):
continue
path = os.path.join(root, name)
try:
text = open(path, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
if not text.strip():
continue
self._notes.append((name[:-3], path, text))
print(f"[KB] 已载入 {len(self._notes)} 篇笔记", flush=True)
@staticmethod
def _ngrams(s: str, n: int = 3):
s = re.sub(r"\s+", "", s)
return {s[i:i + n] for i in range(len(s) - n + 1)} if len(s) >= n else {s}
def search(self, query: str, top_k: int = 3, max_chars: int = 1500) -> str:
"""返回拼接后的相关笔记片段;无结果返回空串。"""
if not self._notes or not query:
return ""
q_grams = self._ngrams(query)
scored = []
for title, path, text in self._notes:
t_grams = self._ngrams(text, 3)
inter = len(q_grams & t_grams)
if inter == 0:
continue
# 重叠数 + 少量标题命中加权
score = inter
if title and self._ngrams(title) & q_grams:
score += 10
scored.append((score, title, text))
if not scored:
return ""
scored.sort(reverse=True, key=lambda x: x[0])
parts = []
for score, title, text in scored[:top_k]:
excerpt = text.strip()
if len(excerpt) > max_chars:
excerpt = excerpt[:max_chars] + ""
parts.append(f"{title}\n{excerpt}")
return "\n\n".join(parts)
# ==================== 每客户会话记忆 ====================
class ConversationMemory:
"""username -> 独立对话历史,内存缓存 + 磁盘持久化。"""
def __init__(self, base_dir: str, max_turns: int):
self.base_dir = base_dir
self.max_turns = max_turns
self._cache = {}
os.makedirs(base_dir, exist_ok=True)
def _safe_name(self, username: str) -> str:
return re.sub(r"[^0-9A-Za-z_@-]", "_", username) + ".json"
def _path(self, username: str) -> str:
return os.path.join(self.base_dir, self._safe_name(username))
def load(self, username: str) -> list:
if username in self._cache:
return self._cache[username]
path = self._path(username)
hist = []
if os.path.exists(path):
try:
hist = json.loads(open(path, "r", encoding="utf-8").read())
except (OSError, ValueError):
hist = []
self._cache[username] = hist
return hist
def append(self, username: str, role: str, content: str):
hist = self.load(username)
hist.append({"role": role, "content": content, "time": int(time.time())})
# 只保留最近 max_turns 轮(一轮 = 用户一条 + 助手一条)
hist = hist[-(self.max_turns * 2):]
self._cache[username] = hist
try:
open(self._path(username), "w", encoding="utf-8").write(
json.dumps(hist, ensure_ascii=False, indent=1)
)
except OSError:
pass
# ==================== 大模型客户端(OpenAI 兼容,零第三方依赖) ====================
class LLMClient:
def __init__(self, cfg: dict):
self.api_key = (cfg.get("api_key") or "").strip()
self.base_url = (cfg.get("base_url") or "").rstrip("/")
self.model = cfg.get("model") or "deepseek-chat"
self.timeout = cfg.get("timeout_sec") or 60
self.max_retry = cfg.get("max_retry") or 2
@property
def available(self) -> bool:
return bool(self.api_key)
def chat(self, messages: list) -> str:
"""调用 chat/completions,返回助手文本;失败抛异常。"""
url = self.base_url + "/chat/completions"
payload = {
"model": self.model,
"messages": messages,
"temperature": 0.7,
"stream": False,
}
data = json.dumps(payload).encode("utf-8")
last_err = None
for attempt in range(self.max_retry + 1):
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + self.api_key)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read().decode("utf-8")
obj = json.loads(body)
return obj["choices"][0]["message"]["content"].strip()
except (urllib.error.URLError, urllib.error.HTTPError, KeyError,
IndexError, ValueError, OSError) as e:
last_err = e
if attempt < self.max_retry:
time.sleep(1 + attempt)
raise RuntimeError(f"LLM 调用失败: {last_err!r}")
# ==================== 引擎主逻辑 ====================
_gui = None
_gui_lock = threading.Lock()
_send_lock = threading.Lock()
_last_reply = {} # username -> 上次回复时间戳
_last_reply["_db"] = None
_self_wxid = None # 当前登录账号的 wxid,用于跳过自己发的消息
_handled_seq = {} # username -> 已处理的最大 sort_seq(消息级去重,双保险)
_sent_recent = {} # username -> [(content, ts)]:引擎刚发出的回复回声,跳过"自己的消息被读回"
_SELF_SENDER_IDS = {"1"} # real_sender_id 里"自己"的取值(微信 4.1.13 实测 self=1
_pending = {} # username -> {"texts": [..], "timer": Timer|None}:冷却期内累积的消息,到期合并回复
_pending_lock = threading.Lock()
def _recent_sent(username: str, now: float, window: float = 120.0):
"""返回该用户最近 window 秒内发出的回复内容列表,用于回声匹配。"""
items = [(c, t) for c, t in _sent_recent.get(username, []) if now - t <= window]
_sent_recent[username] = items
return [c for c, _ in items]
_kb = KnowledgeBase(CFG["knowledge"].get("vault_dir", ""))
_llm = LLMClient(CFG["llm"])
_mem = ConversationMemory(
CFG["storage"].get("conversation_dir", "data/conversations"),
CFG["reply"].get("history_turns", 10),
)
def get_gui():
global _gui
with _gui_lock:
if _gui is None:
_gui = WeChatGUI()
return _gui
_BLOCK_PREFIXES = ("gh_",) # 公众号,不回复
_SYSTEM_USERS = {
"filehelper", "brandsessionholder", "brandservicesessionholder",
"notifymessage", "fmessage", "floatbottle", "medianote",
}
def should_reply(username: str) -> bool:
r = CFG["reply"]
if not r.get("enabled", True):
return False
allowlist = r.get("allowlist") or []
if allowlist:
# 白名单模式:只回复名单内的好友(测试期锁定单一联系人用)
return username in allowlist
if username in r.get("blocklist", []):
return False
if username in _SYSTEM_USERS:
return False
if "@" in username: # 群聊 @chatroom / 服务号 @openim / @weclaw / @placeholder 等
return False
if username.startswith(_BLOCK_PREFIXES):
return False
return True
def _build_messages(username: str, incoming: str) -> tuple:
"""构造发给大模型的 messages 列表,返回 (messages, kb_hit)。"""
sys_prompt = CFG["persona"].get("system_prompt", "")
kb_hit = ""
kb = _kb.search(incoming, top_k=CFG["knowledge"].get("top_k", 3),
max_chars=CFG["knowledge"].get("max_chars_per_note", 1500))
if kb:
kb_hit = kb
sys_prompt += "\n\n【知识库资料】\n" + kb
messages = [{"role": "system", "content": sys_prompt}]
# 取该客户历史(按时间窗口过滤)
hist = _mem.load(username)
window = CFG["reply"].get("history_window_sec", 86400)
now = time.time()
recent = [h for h in hist if now - h.get("time", 0) <= window]
for h in recent[-(CFG["reply"].get("history_turns", 10) * 2):]:
if h.get("content"):
messages.append({"role": h["role"], "content": h["content"]})
messages.append({"role": "user", "content": incoming})
return messages, kb_hit
def _fallback_reply(content: str, kb_hit: str) -> str:
"""无 api_key 时的降级:关键词规则 → 知识库片段 → 兜底话术。"""
rules = CFG["reply"].get("keyword_rules") or {}
for kw, reply in rules.items():
if kw and kw in content:
return reply
if kb_hit:
# 知识库命中但无大模型,只回片段开头的摘录 + 引导
head = kb_hit.strip().split("\n", 1)[0].replace("", "").replace("", "")
return f"关于这个问题,您可以先参考这份资料({head}):需要我展开哪一点,我帮您细看。"
return CFG["reply"].get("fallback_reply",
"【自动回复】已收到您的消息,稍后回复您。")
def _do_reply(username: str, content: str, db):
"""生成并发送一次回复(大模型优先,无 key 降级)。"""
nick = db.get_nickname(username) if db else username
try:
if _llm.available:
messages, kb_hit = _build_messages(username, content)
reply = _llm.chat(messages)
mode = "LLM" + ("+KB" if kb_hit else "")
else:
kb_hit = _kb.search(content, top_k=CFG["knowledge"].get("top_k", 3),
max_chars=CFG["knowledge"].get("max_chars_per_note", 1500))
reply = _fallback_reply(content, kb_hit)
mode = "降级(无key)" + ("+KB" if kb_hit else "")
except Exception as e: # noqa: BLE001
reply = CFG["reply"].get("fallback_reply", "【自动回复】已收到您的消息,稍后回复您。")
mode = f"异常回退:{e!r}"
# 记录历史(用户 + 助手),再发送(加锁,避免 timer 线程与 worker 线程并发写)
with _send_lock:
_mem.append(username, "user", content)
_mem.append(username, "assistant", reply)
_sent_recent.setdefault(username, []).append((reply, time.time()))
try:
r = get_gui().send_msg(reply, who=username, verify=True)
print(f"[智能回复|{mode}] {username} ({nick}) <- {reply} | ok={r.is_success}",
flush=True)
except Exception as e: # noqa: BLE001
print(f"[发送失败] {username}: {e!r}", flush=True)
def _flush_pending(username: str):
"""冷却期结束后,把累积的多条消息合并成一次回复发出。"""
with _pending_lock:
p = _pending.get(username)
if not p or not p["texts"]:
return
texts = p["texts"]
p["texts"] = []
p["timer"] = None
content = " ".join(texts).strip()
if not content:
return
db = _last_reply["_db"]
_last_reply[username] = time.time()
_do_reply(username, content, db)
def on_message(msg: dict, lst: Listener):
username = msg.get("username", "")
sender_id = str(msg.get("sender_id", ""))
content = str(msg.get("content", "")).strip()
sort_seq = msg.get("sort_seq", 0)
mtype = msg.get("type", "")
local_id = msg.get("local_id")
# 跳过自己发的(避免回复自己的回复 → 死循环)。
now = time.time()
# ---- 跳过"自己"的消息(三层防护,杜绝自回复死循环)----
# 1) sender_id 是真实 wxid 字符串时
if _self_wxid and sender_id == str(_self_wxid):
return
# 2) real_sender_id 数值 == 自己(微信 4.1.13 实测 self=1
if sender_id in _SELF_SENDER_IDS:
return
# 3) 内容回声:刚发出去的回复又被监听读回,直接丢弃(不依赖 sender_id 语义,最稳一层)
if content and content in _recent_sent(username, now):
return
if not should_reply(username) or not content:
return
# 消息级去重:sort_seq 不前进则说明是同一条消息被重复回调,直接丢弃
last_seq = _handled_seq.get(username, -1)
if sort_seq and sort_seq <= last_seq:
return
_handled_seq[username] = sort_seq
db = _last_reply["_db"]
# 语音消息:先转文字再回复(语音 content 是 "[语音]" 占位,真实内容在音频里)
if mtype == "语音" and local_id:
text = voice2text.transcribe_voice(username, local_id, db)
if text:
content = text
print(f"[语音转文字] {username} -> {text}", flush=True)
else:
print(f"[语音转文字] {username} 转写失败,跳过", flush=True)
return
# 冷却合并:冷却期内的新消息不丢弃,累积后统一回复(修复"只回第一句、后面不回")。
cooldown = CFG["reply"].get("cooldown_sec", 5)
if now - _last_reply.get(username, 0) < cooldown:
with _pending_lock:
p = _pending.setdefault(username, {"texts": [], "timer": None})
p["texts"].append(content)
if p["timer"] is not None:
p["timer"].cancel()
p["timer"] = threading.Timer(cooldown, _flush_pending, args=(username,))
p["timer"].daemon = True
p["timer"].start()
return
_last_reply[username] = now
_do_reply(username, content, db)
def main():
global _self_wxid
# 单实例锁:防止多个引擎进程并发读写同一个解密缓存导致 database malformed
_lock_path = os.path.join(BASE_DIR, "engine.lock")
_lock_fh = open(_lock_path, "wb")
try:
msvcrt.locking(_lock_fh.fileno(), msvcrt.LK_NBLCK, 1)
except OSError:
print("已有引擎实例在运行,本进程退出(避免并发写坏数据库缓存)。", flush=True)
_lock_fh.close()
sys.exit(0)
db = WeChatDB()
info = db.get_self_info()
nick = info.get("nick_name") or info.get("username")
_last_reply["_db"] = db
_self_wxid = info.get("username") or getattr(db, "wxid", None)
print(f"已接管微信: {nick} ({info.get('username')})", flush=True)
print(f"自身 wxid: {_self_wxid}(跳过自己发的消息,防自回复循环)", flush=True)
print(f"大模型: {'已配置 ' + _llm.model if _llm.available else '未配置(降级模式)'} "
f"| base_url={_llm.base_url}", flush=True)
print(f"知识库: {CFG['knowledge'].get('vault_dir','')} (已载入 {len(_kb._notes)} 篇)",
flush=True)
_allow = CFG['reply'].get('allowlist') or []
_scope = f"白名单 {len(_allow)}" if _allow else "所有好友(排除公众号/群聊/服务号)"
print(f"自动回复: {'' if CFG['reply'].get('enabled') else ''} | "
f"回复范围: {_scope} | "
f"防抖 {CFG['reply'].get('cooldown_sec')}s", flush=True)
lst = Listener(db, interval=3.0, watermark=load_watermark())
# 监听所有会话(add_all + discover 自动发现新会话)。should_reply 里排除
# 公众号/群聊/服务号/系统账号;若配置 allowlist 则只回名单内好友。
lst.add_all(on_message, discover=True)
print(f"智能客服监听已启动({_scope}),Ctrl+C 退出...",
flush=True)
lst.start()
_save_every = 0
try:
while True:
time.sleep(5)
_save_every += 5
if _save_every >= 30:
_save_every = 0
save_watermark(lst.watermark)
except KeyboardInterrupt:
pass
finally:
save_watermark(lst.watermark)
lst.stop()
print("已停止监听。", flush=True)
if __name__ == "__main__":
main()