's'
This commit is contained in:
@@ -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,"<").replace(/>/g,">");
|
||||
const uid = (u.userId || "").replace(/</g,"<").replace(/>/g,">");
|
||||
const last = (u.lastMsg || "").replace(/</g,"<").replace(/>/g,">");
|
||||
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>
|
||||
|
||||
232
app/services/wechat_private_user_pb.py
Normal file
232
app/services/wechat_private_user_pb.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
微信 Chatbot 开放对话回调后:从 chatbot.weixin 联系人接口拉取资料,写入 PocketBase wechat_private_users。
|
||||
/chatbot(eric)与 /hackrobot(xidu)共用,按 profile_key 选用不同 wxbot_bid。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WECHAT_PRIVATE_USERS_COLLECTION = "wechat_private_users"
|
||||
|
||||
# 与线上一致:默认同一套 JWT 可拉多个 bid;异度单独 bid。可用环境变量覆盖。
|
||||
_DEFAULT_PM_SYNC_AUTHTOKEN = (
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4"
|
||||
)
|
||||
_DEFAULT_WXBOT_BID_ERIC = "75021053883a9090c67d5becde7f4b0a"
|
||||
_DEFAULT_WXBOT_BID_XIDU = "4888c6ab5cb70dcc3adea1e2d2ff0201"
|
||||
|
||||
_PM_PROFILE_KEYS = frozenset({"eric", "xidu"})
|
||||
|
||||
_processed_callback_cache: dict[str, int] = {}
|
||||
|
||||
|
||||
def normalize_pm_profile_key(profile_key: str) -> str:
|
||||
k = (profile_key or "").strip().lower()
|
||||
return k if k in _PM_PROFILE_KEYS else "eric"
|
||||
|
||||
|
||||
def pm_sync_credentials_for_profile(profile_key: str) -> tuple[str, str]:
|
||||
"""返回 (authtoken, wxbot_bid),供 getaccessstautuslist 使用。"""
|
||||
k = normalize_pm_profile_key(profile_key)
|
||||
base_token = (os.getenv("PM_SYNC_AUTHTOKEN") or _DEFAULT_PM_SYNC_AUTHTOKEN).strip()
|
||||
if k == "xidu":
|
||||
token = (os.getenv("PM_SYNC_AUTHTOKEN_XIDU") or base_token).strip()
|
||||
bid = (
|
||||
os.getenv("PM_SYNC_WXBOT_BID_XIDU")
|
||||
or os.getenv("PM_SYNC_DEFAULT_WXBOT_BID_XIDU")
|
||||
or _DEFAULT_WXBOT_BID_XIDU
|
||||
).strip()
|
||||
else:
|
||||
token = base_token
|
||||
bid = (
|
||||
os.getenv("PM_SYNC_WXBOT_BID")
|
||||
or os.getenv("PM_SYNC_DEFAULT_WXBOT_BID")
|
||||
or _DEFAULT_WXBOT_BID_ERIC
|
||||
).strip()
|
||||
return token, bid
|
||||
|
||||
|
||||
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_wechat_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)
|
||||
pk = normalize_pm_profile_key(profile_key)
|
||||
key = pk + "|" + _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_wechat_private_user(record: dict, *, collection: Optional[str] = None) -> None:
|
||||
coll = (collection or WECHAT_PRIVATE_USERS_COLLECTION).strip() or WECHAT_PRIVATE_USERS_COLLECTION
|
||||
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)
|
||||
filter_exp = f'userid="{user_expr}"'
|
||||
list_resp = await client.get(
|
||||
f"{pb_url}/api/collections/{coll}/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/{coll}/records/{rid}", json=record, headers=headers)
|
||||
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/{coll}/records/{extra_id}",
|
||||
headers=headers,
|
||||
)
|
||||
return
|
||||
await client.post(f"{pb_url}/api/collections/{coll}/records", json=record, headers=headers)
|
||||
|
||||
|
||||
def normalize_wechat_avatar_url(url: str) -> str:
|
||||
"""微信头像常为 http://wx.qlogo.cn;在 HTTPS 站点作为 <img src> 会触发混合内容拦截,同路径可改用 https。"""
|
||||
s = str(url or "").strip()
|
||||
low = s.lower()
|
||||
if low.startswith("http://") and "qlogo.cn" in low:
|
||||
return "https://" + s[7:]
|
||||
return s
|
||||
|
||||
|
||||
def contact_avatar_url(contact: dict) -> str:
|
||||
v = (
|
||||
contact.get("headImg")
|
||||
or contact.get("avatar")
|
||||
or contact.get("headimgurl")
|
||||
or contact.get("headImgUrl")
|
||||
)
|
||||
return normalize_wechat_avatar_url(str(v or "").strip())
|
||||
|
||||
|
||||
async def sync_wechat_private_user_to_pb(data: dict, profile_key: str) -> None:
|
||||
"""根据解密后的用户消息 data,拉取 B 端联系人并 upsert 到 PocketBase。"""
|
||||
authtoken, wxbot_bid = pm_sync_credentials_for_profile(profile_key)
|
||||
if not authtoken or not wxbot_bid:
|
||||
return
|
||||
target_userid = str(data.get("UserId", "")).strip()
|
||||
if not target_userid or is_duplicate_wechat_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_wechat_private_user(
|
||||
{
|
||||
"userid": target_userid,
|
||||
"channel_id": str(data.get("ChannelId", "")).strip(),
|
||||
"nick": target.get("nick", ""),
|
||||
"avatar": contact_avatar_url(target) 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_wechat_private_user_to_pb(data: dict, profile_key: str) -> None:
|
||||
try:
|
||||
await sync_wechat_private_user_to_pb(data, profile_key)
|
||||
except Exception:
|
||||
logger.exception("微信私信用户同步 PocketBase 失败(已忽略) profile=%s", profile_key)
|
||||
Reference in New Issue
Block a user