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 logging
import os import os
import struct import struct
import time
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import NamedTuple, Optional from typing import NamedTuple, Optional
@@ -15,14 +14,16 @@ from Crypto.Cipher import AES
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse 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.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"]) router = APIRouter(tags=["chatbot"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_processed_callback_cache: dict[str, int] = {}
OPEN_API_PROFILES = { OPEN_API_PROFILES = {
"eric": { "eric": {
@@ -56,11 +57,6 @@ def _resolve_profile_key(profile_key: str) -> str:
def _profile_creds(profile_key: str) -> dict: def _profile_creds(profile_key: str) -> dict:
return OPEN_API_PROFILES[_resolve_profile_key(profile_key)] 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: def pkcs5_unpadding(data: bytes) -> bytes:
pad = data[-1] pad = data[-1]
if pad < 1 or pad > 32: if pad < 1 or pad > 32:
@@ -121,7 +117,7 @@ class SharedChatbotReply(NamedTuple):
"""全公众号配置共用的解析结果:要么 H5 图文,要么兜底纯文本(含超链)。""" """全公众号配置共用的解析结果:要么 H5 图文,要么兜底纯文本(含超链)。"""
h5_message: Optional[dict] h5_message: Optional[dict]
"""与「发送客服消息」H5 一致:{\"news\": {\"articles\": [...]}},未命中关键词时为 None。""" """与「发送客服消息」H5 一致:{\"news\": {\"articles\": [...]}};关键词为 Query 子串包含命中,未命中时为 None。"""
text_fallback: str text_fallback: str
"""未命中关键词时的 short_answer 文案(可含 <a href>)。""" """未命中关键词时的 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]: 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() q = (query or "").strip()
uid = (user_id or "").strip() uid = (user_id or "").strip()
prof = _resolve_profile_key(profile_key) 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: 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() user_id = str(data.get("UserId", "") or data.get("userId", "") or "").strip()
query = str(data.get("Query", "") or "").strip() query = str(data.get("Query", "") or "").strip()
h5 = shared_build_h5_keyword_message(query, user_id, profile_key) 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") 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( async def _handle_wechat_request(
request: Request, request: Request,
app_id: Optional[str], app_id: Optional[str],
@@ -587,7 +442,7 @@ async def _handle_wechat_request(
raise HTTPException(status_code=400, detail="decrypt error") raise HTTPException(status_code=400, detail="decrypt error")
reply = shared_resolve_reply(data, profile_key) 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 "") fmt = str(data.get("_payload_format", "") or "")
plain_obj = shared_build_plain_body_for_wechat_callback(payload_format=fmt, reply=reply, data=data) 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"), "nick": c.get("nick"),
"userId": c.get("userId") or c.get("userid"), "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"), "lastMsg": (c.get("lastMsg") or {}).get("content"),
"lastActiveTime": c.get("lastActiveTime"), "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 { border: 0; background: #2563eb; color: white; border-radius: 8px; padding: 10px 14px; cursor: pointer; }
button.alt { background: #4f46e5; } button.alt { background: #4f46e5; }
#status { font-size: 13px; color: #93c5fd; } #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; } #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; } .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; } pre { background:#020617; border:1px solid #334155; border-radius:8px; padding:10px; overflow:auto; max-height:420px; }
</style> </style>
@@ -824,6 +687,13 @@ def _private_message_debug_html(api_prefix: str) -> str:
<section class="card"> <section class="card">
<h2>Chatbot 私信调试台</h2> <h2>Chatbot 私信调试台</h2>
<div id="status">准备就绪</div> <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> <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;"> <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> <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 setStatus(s){ $("status").textContent = s; }
function renderJson(v){ $("out").textContent = JSON.stringify(v, null, 2); } 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){ function renderUsers(users){
const box = $("users"); const box = $("users");
box.replaceChildren();
if(!Array.isArray(users) || !users.length){ 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; return;
} }
box.innerHTML = users.map(u => { for(const u of users){
const nick = (u.nick || "未命名").replace(/</g,"&lt;").replace(/>/g,"&gt;"); const row = document.createElement("div");
const uid = (u.userId || "").replace(/</g,"&lt;").replace(/>/g,"&gt;"); row.className = "u";
const last = (u.lastMsg || "").replace(/</g,"&lt;").replace(/>/g,"&gt;"); const rawAv = String(u.avatar || "").trim();
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>`; const av = normalizeAvatarUrl(rawAv);
}).join(""); 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){ function applyPreset(key){
@@ -893,6 +877,7 @@ def _private_message_debug_html(api_prefix: str) -> str:
if(!p) return; if(!p) return;
$("authtoken").value = p.authtoken; $("authtoken").value = p.authtoken;
$("wxbot_bid").value = p.wxbot_bid; $("wxbot_bid").value = p.wxbot_bid;
startTokenCountdown();
} }
async function queryPm(){ async function queryPm(){
@@ -931,6 +916,7 @@ def _private_message_debug_html(api_prefix: str) -> str:
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
setStatus(res.ok ? "分析成功" : "分析失败"); setStatus(res.ok ? "分析成功" : "分析失败");
renderJson(data); renderJson(data);
startTokenCountdown();
}catch(e){ }catch(e){
setStatus("请求异常"); setStatus("请求异常");
renderJson({ error: String(e) }); 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)); $("preset").addEventListener("change", (e) => applyPreset(e.target.value));
$("authtoken").addEventListener("input", function(){ startTokenCountdown(); });
applyPreset("eric"); applyPreset("eric");
</script> </script>
</body> </body>

View File

@@ -0,0 +1,232 @@
"""
微信 Chatbot 开放对话回调后:从 chatbot.weixin 联系人接口拉取资料,写入 PocketBase wechat_private_users。
/chatboteric与 /hackrobotxidu共用按 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)