's'
This commit is contained in:
@@ -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,"<").replace(/>/g,">");
|
const row = document.createElement("div");
|
||||||
const uid = (u.userId || "").replace(/</g,"<").replace(/>/g,">");
|
row.className = "u";
|
||||||
const last = (u.lastMsg || "").replace(/</g,"<").replace(/>/g,">");
|
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>
|
||||||
|
|||||||
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