This commit is contained in:
eric
2026-04-02 01:04:08 -05:00
parent 8c3483c360
commit 1c5b46a933
2 changed files with 386 additions and 167 deletions

View File

@@ -4,7 +4,6 @@ import json
import logging
import os
import struct
import time
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from typing import NamedTuple, Optional
@@ -15,14 +14,16 @@ from Crypto.Cipher import AES
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from ..config import PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL
from ..services.mp_recommend import is_mp_recommend_webhook_body, try_persist_from_like_body
from ..services.wechat_private_user_pb import (
contact_avatar_url,
query_private_messages,
safe_sync_wechat_private_user_to_pb,
)
router = APIRouter(tags=["chatbot"])
logger = logging.getLogger(__name__)
_processed_callback_cache: dict[str, int] = {}
OPEN_API_PROFILES = {
"eric": {
@@ -56,11 +57,6 @@ def _resolve_profile_key(profile_key: str) -> str:
def _profile_creds(profile_key: str) -> dict:
return OPEN_API_PROFILES[_resolve_profile_key(profile_key)]
PM_SYNC_AUTHTOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4"
PM_SYNC_DEFAULT_WXBOT_BID = "75021053883a9090c67d5becde7f4b0a"
PB_COLLECTION = "wechat_private_users"
def pkcs5_unpadding(data: bytes) -> bytes:
pad = data[-1]
if pad < 1 or pad > 32:
@@ -121,7 +117,7 @@ class SharedChatbotReply(NamedTuple):
"""全公众号配置共用的解析结果:要么 H5 图文,要么兜底纯文本(含超链)。"""
h5_message: Optional[dict]
"""与「发送客服消息」H5 一致:{\"news\": {\"articles\": [...]}},未命中关键词时为 None。"""
"""与「发送客服消息」H5 一致:{\"news\": {\"articles\": [...]}};关键词为 Query 子串包含命中,未命中时为 None。"""
text_fallback: str
"""未命中关键词时的 short_answer 文案(可含 <a href>)。"""
@@ -155,7 +151,11 @@ def _normalize_h5_msg_per_wechat_doc(h5_message: dict) -> dict:
def shared_build_h5_keyword_message(query: str, user_id: str, profile_key: str) -> Optional[dict]:
"""关键词命中时返回文档约定的 H5 msg再经 _normalize 输出)。异度(/hackrobot) 群卡片仅认「群」Eric(/chatbot) 另认「加群」。"""
"""关键词命中时返回文档约定的 H5 msg再经 _normalize 输出)。
命中规则:对用户 Query 首尾 trim 后,**包含**配置词作为连续子串即命中(子串匹配),
不要求用户整句与关键词全等。异度(/hackrobot) 群卡片仅认「群」Eric(/chatbot) 另认「加群」。
"""
q = (query or "").strip()
uid = (user_id or "").strip()
prof = _resolve_profile_key(profile_key)
@@ -206,7 +206,7 @@ def build_fallback_reply_plain_text(user_id: str) -> str:
def shared_resolve_reply(data: dict, profile_key: str) -> SharedChatbotReply:
"""根据解密后的 data 与 profile 决定 H5 或兜底;关键词时一律走 build_fallback_reply_plain_text。"""
"""根据解密后的 data 与 profile 决定 H5 或兜底;关键词按 Query 子串包含判断,无命中时走 build_fallback_reply_plain_text。"""
user_id = str(data.get("UserId", "") or data.get("userId", "") or "").strip()
query = str(data.get("Query", "") or "").strip()
h5 = shared_build_h5_keyword_message(query, user_id, profile_key)
@@ -369,151 +369,6 @@ def _parse_decrypted_payload(decrypted_text: str) -> dict:
raise ValueError("unsupported payload format")
def _safe_filter_value(value: str) -> str:
return (value or "").replace("\\", "\\\\").replace('"', '\\"')
def _build_callback_dedupe_key(data: dict) -> str:
return "|".join(
[
str(data.get("UserId", "")).strip(),
str(data.get("MsgId", "")).strip(),
str(data.get("Timestamp", "")).strip(),
str(data.get("Query", "")).strip(),
]
)
def _is_duplicate_callback(data: dict, profile_key: str, ttl_seconds: int = 3600) -> bool:
now_ts = int(time.time())
for k, exp in list(_processed_callback_cache.items()):
if exp <= now_ts:
_processed_callback_cache.pop(k, None)
key = _resolve_profile_key(profile_key) + "|" + _build_callback_dedupe_key(data)
if not key.strip("|"):
return False
if key in _processed_callback_cache:
return True
_processed_callback_cache[key] = now_ts + ttl_seconds
return False
async def query_private_messages(
authtoken: str,
wxbot_bid: str,
page: int = 0,
size: int = 30,
filter_value: int = 0,
request_id: str = "local-debug",
) -> dict:
url = "https://chatbot.weixin.qq.com/miniopenai/manualservice/getaccessstautuslist"
body = {"bid": wxbot_bid, "filter": filter_value, "order": {"page": page, "size": size}, "base": {"requestid": request_id}}
headers = {
"authtoken": authtoken,
"wxbot_bid": wxbot_bid,
"content-type": "application/json",
"charset": "utf-8",
}
async with httpx.AsyncClient(timeout=10, verify=False) as client:
resp = await client.post(url, json=body, headers=headers)
resp.raise_for_status()
return resp.json()
async def _pb_upsert_user_profile(record: dict) -> None:
pb_url = (PB_URL or "").rstrip("/")
if not pb_url or not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
return
userid = str(record.get("userid", "")).strip()
if not userid:
return
async with httpx.AsyncClient(timeout=10, verify=False) as client:
auth_resp = await client.post(
f"{pb_url}/api/admins/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
)
if auth_resp.status_code == 404:
auth_resp = await client.post(
f"{pb_url}/api/collections/_superusers/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
)
if auth_resp.status_code != 200:
return
token = (auth_resp.json() or {}).get("token", "")
if not token:
return
headers = {"Authorization": f"Bearer {token}"}
user_expr = _safe_filter_value(userid)
# 业务要求wechat_private_users.userid 全局唯一(不按 channel_id 分桶)
filter_exp = f'userid="{user_expr}"'
list_resp = await client.get(
f"{pb_url}/api/collections/{PB_COLLECTION}/records",
params={"page": 1, "perPage": 200, "filter": filter_exp, "sort": "-updated"},
headers=headers,
)
items = ((list_resp.json() or {}).get("items")) or [] if list_resp.status_code == 200 else []
if items:
rid = items[0].get("id")
if rid:
await client.patch(f"{pb_url}/api/collections/{PB_COLLECTION}/records/{rid}", json=record, headers=headers)
# 清理历史重复数据,确保 userid 唯一
for extra in items[1:]:
extra_id = str(extra.get("id", "")).strip()
if not extra_id:
continue
await client.delete(
f"{pb_url}/api/collections/{PB_COLLECTION}/records/{extra_id}",
headers=headers,
)
return
await client.post(f"{pb_url}/api/collections/{PB_COLLECTION}/records", json=record, headers=headers)
async def _sync_user_profile_from_b(data: dict, profile_key: str) -> None:
authtoken = (PM_SYNC_AUTHTOKEN or "").strip()
wxbot_bid = (PM_SYNC_DEFAULT_WXBOT_BID or "").strip()
if not authtoken or not wxbot_bid:
return
target_userid = str(data.get("UserId", "")).strip()
if not target_userid or _is_duplicate_callback(data, profile_key):
return
result = await query_private_messages(
authtoken=authtoken,
wxbot_bid=wxbot_bid,
page=0,
size=100,
filter_value=0,
request_id=f"thirdapi-{target_userid}",
)
contacts = result.get("contacts") or []
target = next((c for c in contacts if str(c.get("userId", "") or c.get("userid", "")).strip() == target_userid), None)
if not target:
return
last_msg = target.get("lastMsg") or {}
await _pb_upsert_user_profile(
{
"userid": target_userid,
"channel_id": str(data.get("ChannelId", "")).strip(),
"nick": target.get("nick", ""),
"avatar": target.get("headImg") or target.get("avatar") or target.get("headimgurl") or target.get("headImgUrl") or "",
"source_query": str(data.get("Query", "")).strip(),
"source_message_id": str(data.get("MsgId", "")).strip(),
"source_message_time": str(data.get("Timestamp", "")),
"matched_last_msg": last_msg.get("content", ""),
"matched_last_msg_id": str(last_msg.get("msgId", "") or last_msg.get("id", "")),
"matched_last_msg_time": str(target.get("lastActiveTime", "")),
"raw_contact": json.dumps(target, ensure_ascii=False),
}
)
async def _safe_sync_user_profile_from_b(data: dict, profile_key: str) -> None:
try:
await _sync_user_profile_from_b(data, profile_key)
except Exception:
logger.error("chatbot后台同步失败已忽略")
async def _handle_wechat_request(
request: Request,
app_id: Optional[str],
@@ -587,7 +442,7 @@ async def _handle_wechat_request(
raise HTTPException(status_code=400, detail="decrypt error")
reply = shared_resolve_reply(data, profile_key)
background_tasks.add_task(_safe_sync_user_profile_from_b, data, profile_key)
background_tasks.add_task(safe_sync_wechat_private_user_to_pb, data, profile_key)
fmt = str(data.get("_payload_format", "") or "")
plain_obj = shared_build_plain_body_for_wechat_callback(payload_format=fmt, reply=reply, data=data)
@@ -746,7 +601,7 @@ async def chatbot_private_message_query(payload: dict):
{
"nick": c.get("nick"),
"userId": c.get("userId") or c.get("userid"),
"avatar": c.get("avatar") or c.get("headImg") or c.get("headimgurl") or c.get("headImgUrl"),
"avatar": contact_avatar_url(c) or None,
"lastMsg": (c.get("lastMsg") or {}).get("content"),
"lastActiveTime": c.get("lastActiveTime"),
}
@@ -813,8 +668,16 @@ def _private_message_debug_html(api_prefix: str) -> str:
button { border: 0; background: #2563eb; color: white; border-radius: 8px; padding: 10px 14px; cursor: pointer; }
button.alt { background: #4f46e5; }
#status { font-size: 13px; color: #93c5fd; }
#token-countdown-wrap { margin-top: 12px; display: none; }
#token-countdown-wrap.active { display: block; }
.cd-row { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #94a3b8; gap: 8px; }
.cd-bar { height: 8px; background: #1e293b; border-radius: 4px; overflow: hidden; margin-top: 8px; }
.cd-bar-fill { height: 100%; width: 100%; border-radius: 4px; transition: width 0.35s linear, background 0.25s; background: #22c55e; }
#users { display:grid; grid-template-columns: repeat(auto-fill,minmax(250px,1fr)); gap: 8px; }
.u { border:1px solid #334155; border-radius:8px; padding:8px; background:#0b1220; }
.u { border:1px solid #334155; border-radius:8px; padding:8px; background:#0b1220; display:flex; gap:10px; align-items:flex-start; }
.u .ava { width:40px; height:40px; border-radius:8px; object-fit:cover; flex-shrink:0; background:#1e293b; }
.u .ava--ph { display:flex; align-items:center; justify-content:center; font-size:10px; color:#64748b; text-align:center; line-height:1.2; padding:2px; box-sizing:border-box; }
.u .meta { min-width:0; flex:1; }
.uid { color:#93c5fd; font-size:12px; word-break:break-all; }
pre { background:#020617; border:1px solid #334155; border-radius:8px; padding:10px; overflow:auto; max-height:420px; }
</style>
@@ -824,6 +687,13 @@ def _private_message_debug_html(api_prefix: str) -> str:
<section class="card">
<h2>Chatbot 私信调试台</h2>
<div id="status">准备就绪</div>
<div id="token-countdown-wrap">
<div class="cd-row">
<span>Token 剩余</span>
<span id="token-countdown-text">--</span>
</div>
<div class="cd-bar"><div id="token-countdown-bar" class="cd-bar-fill"></div></div>
</div>
<label>默认配置</label>
<select id="preset" style="width:100%;border-radius:8px;border:1px solid #475569;background:#0b1220;color:#e2e8f0;padding:10px;box-sizing:border-box;">
<option value="eric">Eric在旅行</option>
@@ -874,18 +744,132 @@ def _private_message_debug_html(api_prefix: str) -> str:
};
function setStatus(s){ $("status").textContent = s; }
function renderJson(v){ $("out").textContent = JSON.stringify(v, null, 2); }
/** HTTPS 页内嵌 http://wx.qlogo.cn 会被浏览器拦截,改为 https 同路径即可加载 */
function normalizeAvatarUrl(av){
const s = (av || "").trim();
if(!s) return "";
const low = s.toLowerCase();
if(low.startsWith("http://") && low.indexOf("qlogo.cn") >= 0) return "https://" + s.slice(7);
return s;
}
function isAvatarImgUrl(s){
const t = (s || "").trim().toLowerCase();
return t.startsWith("https://") || t.startsWith("http://");
}
function mkAvaPlaceholder(title, hint){
const d = document.createElement("div");
d.className = "ava ava--ph";
d.title = title || "";
d.textContent = hint || "无图";
return d;
}
function renderUsers(users){
const box = $("users");
box.replaceChildren();
if(!Array.isArray(users) || !users.length){
box.innerHTML = '<div class="u">暂无数据</div>';
const empty = document.createElement("div");
empty.className = "u";
empty.textContent = "暂无数据";
box.appendChild(empty);
return;
}
box.innerHTML = users.map(u => {
const nick = (u.nick || "未命名").replace(/</g,"&lt;").replace(/>/g,"&gt;");
const uid = (u.userId || "").replace(/</g,"&lt;").replace(/>/g,"&gt;");
const last = (u.lastMsg || "").replace(/</g,"&lt;").replace(/>/g,"&gt;");
return `<div class="u"><div>${nick}</div><div class="uid">${uid}</div><div style="font-size:12px;color:#94a3b8;margin-top:4px;">${last}</div></div>`;
}).join("");
for(const u of users){
const row = document.createElement("div");
row.className = "u";
const rawAv = String(u.avatar || "").trim();
const av = normalizeAvatarUrl(rawAv);
if(av && isAvatarImgUrl(av)){
const img = document.createElement("img");
img.className = "ava";
img.alt = "";
img.referrerPolicy = "no-referrer";
img.loading = "lazy";
img.decoding = "async";
img.src = av;
img.onerror = function(){
const ph = mkAvaPlaceholder("头像加载失败", "失败");
this.replaceWith(ph);
};
row.appendChild(img);
}else{
row.appendChild(mkAvaPlaceholder(rawAv ? "非浏览器可加载的链接" : "接口未返回头像", rawAv ? "?" : ""));
}
const meta = document.createElement("div");
meta.className = "meta";
const nickEl = document.createElement("div");
nickEl.textContent = u.nick || "未命名";
const uidEl = document.createElement("div");
uidEl.className = "uid";
uidEl.textContent = u.userId || "";
const lastEl = document.createElement("div");
lastEl.style.cssText = "font-size:12px;color:#94a3b8;margin-top:4px;";
lastEl.textContent = u.lastMsg || "";
meta.appendChild(nickEl);
meta.appendChild(uidEl);
meta.appendChild(lastEl);
row.appendChild(meta);
box.appendChild(row);
}
}
let _tokenCdTimer = null;
let _tokenCdSpan = 0;
function parseJwtPayload(token){
try{
const parts = String(token || "").trim().split(".");
if(parts.length < 2) return null;
let p = parts[1].replace(/-/g,"+").replace(/_/g,"/");
p += "=".repeat((4 - (p.length % 4)) % 4);
return JSON.parse(atob(p));
}catch(e){ return null; }
}
function formatRemain(sec){
if(sec <= 0) return "已过期";
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if(d > 0) return d + "" + h + "小时";
if(h > 0) return h + "小时 " + m + "";
if(m > 0) return m + "" + s + "";
return s + "";
}
function stopTokenCountdown(){
if(_tokenCdTimer){ clearInterval(_tokenCdTimer); _tokenCdTimer = null; }
}
function startTokenCountdown(){
stopTokenCountdown();
const wrap = $("token-countdown-wrap");
const textEl = $("token-countdown-text");
const bar = $("token-countdown-bar");
const token = $("authtoken").value.trim();
const pl = parseJwtPayload(token);
const exp = pl && typeof pl.exp === "number" ? pl.exp : null;
const iat = pl && typeof pl.iat === "number" ? pl.iat : null;
if(exp == null){
wrap.classList.remove("active");
return;
}
wrap.classList.add("active");
const now0 = Math.floor(Date.now() / 1000);
_tokenCdSpan = Math.max(120, exp - (iat || now0));
function tick(){
const now = Math.floor(Date.now() / 1000);
const left = exp - now;
const pct = Math.max(0, Math.min(100, (left / _tokenCdSpan) * 100));
textEl.textContent = formatRemain(left);
bar.style.width = pct + "%";
if(left > _tokenCdSpan * 0.5) bar.style.background = "#22c55e";
else if(left > _tokenCdSpan * 0.2) bar.style.background = "#eab308";
else bar.style.background = "#ef4444";
if(left <= 0){
textEl.textContent = "已过期";
bar.style.width = "0%";
stopTokenCountdown();
}
}
tick();
_tokenCdTimer = setInterval(tick, 1000);
}
function applyPreset(key){
@@ -893,6 +877,7 @@ def _private_message_debug_html(api_prefix: str) -> str:
if(!p) return;
$("authtoken").value = p.authtoken;
$("wxbot_bid").value = p.wxbot_bid;
startTokenCountdown();
}
async function queryPm(){
@@ -931,6 +916,7 @@ def _private_message_debug_html(api_prefix: str) -> str:
const data = await res.json().catch(() => ({}));
setStatus(res.ok ? "分析成功" : "分析失败");
renderJson(data);
startTokenCountdown();
}catch(e){
setStatus("请求异常");
renderJson({ error: String(e) });
@@ -938,6 +924,7 @@ def _private_message_debug_html(api_prefix: str) -> str:
}
$("preset").addEventListener("change", (e) => applyPreset(e.target.value));
$("authtoken").addEventListener("input", function(){ startTokenCountdown(); });
applyPreset("eric");
</script>
</body>