Files
gitlab-instance-0a899031_pa…/app/routers/chatbot.py
2026-03-31 13:05:02 -05:00

684 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import base64
import json
import logging
import os
import struct
import time
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import parse_qs
import httpx
from Crypto.Cipher import AES
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse
from ..config import PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL
router = APIRouter(prefix="/chatbot", tags=["chatbot"])
logger = logging.getLogger(__name__)
_processed_callback_cache: dict[str, int] = {}
OPEN_API_PROFILES = {
"eric": {
"name": "Eric在旅行",
"APP_ID": "fXYWvGfq04uMScP",
"TOKEN": "EWRotyMHfTLAlYmCHpdH8AZcuZPBHn",
"ENCODING_AES_KEY": "1wJZiJm8URfRjkHu1QfIVDeNFc2jbzzeCZqvhQecsCx",
},
"xidu": {
"name": "异度世界",
"APP_ID": "LTKfSFeK0h8TRLM",
"TOKEN": "64DbvAd5m7YOJ1XslyMgJ9V42jDIUH",
"ENCODING_AES_KEY": "KCZGtPDjqFZnBuApvG3xqhvHUMnqFHbC5cnD23HKACX",
},
}
ACTIVE_PROFILE = (os.getenv("CHATBOT_ACTIVE_PROFILE") or "eric").strip().lower()
if ACTIVE_PROFILE not in OPEN_API_PROFILES:
ACTIVE_PROFILE = "eric"
APP_ID = OPEN_API_PROFILES[ACTIVE_PROFILE]["APP_ID"]
TOKEN = OPEN_API_PROFILES[ACTIVE_PROFILE]["TOKEN"]
ENCODING_AES_KEY = OPEN_API_PROFILES[ACTIVE_PROFILE]["ENCODING_AES_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:
return data
return data[:-pad]
def pkcs5_padding(data: bytes) -> bytes:
amount_to_pad = 32 - (len(data) % 32)
return data + bytes([amount_to_pad]) * amount_to_pad
def get_aes_key_and_iv(encoding_aes_key: str) -> tuple[bytes, bytes]:
aes_key = base64.b64decode(encoding_aes_key + "=")
if len(aes_key) != 32:
raise ValueError("encodingAESKey invalid")
return aes_key, aes_key[:16]
def aes_decrypt_base64(cipher_b64: str, encoding_aes_key: str) -> str:
aes_key, iv = get_aes_key_and_iv(encoding_aes_key)
compact = "".join(str(cipher_b64 or "").split())
compact += "=" * (-len(compact) % 4)
cipher_bytes = base64.b64decode(compact)
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
decrypted = pkcs5_unpadding(cipher.decrypt(cipher_bytes))
try:
if len(decrypted) < 20:
raise ValueError("payload too short")
content = decrypted[16:]
msg_len = struct.unpack("!I", content[:4])[0]
msg_start = 4
msg_end = msg_start + msg_len
if msg_end > len(content):
raise ValueError("msg length out of range")
msg = content[msg_start:msg_end]
text = msg.decode("utf-8")
if text:
return text
except Exception:
pass
return decrypted.decode("utf-8")
def aes_encrypt_base64(plaintext: str, encoding_aes_key: str, app_id: str | None = None) -> str:
aes_key, iv = get_aes_key_and_iv(encoding_aes_key)
raw = plaintext.encode("utf-8")
if app_id:
data = os.urandom(16) + struct.pack("!I", len(raw)) + raw + app_id.encode("utf-8")
else:
data = raw
padded = pkcs5_padding(data)
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
return base64.b64encode(cipher.encrypt(padded)).decode("utf-8")
def _build_keyword_h5_obj(query: str, user_id: str) -> Optional[dict]:
q = (query or "").strip()
if "电子书" in q:
return {
"news": {
"articles": [
{
"title": "数字游民",
"description": "地理套利与自动化杠杆",
"url": f"https://vip.hackrobot.cn/ebook?userid={user_id}",
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
"type": "h5",
}
]
}
}
if "" in q:
return {
"news": {
"articles": [
{
"title": "异度星球",
"description": "电子书、视频教程、私密社群",
"url": f"https://vip.hackrobot.cn?userid={user_id}",
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
"type": "h5",
}
]
}
}
return None
def _parse_decrypted_payload(decrypted_text: str) -> dict:
text = (decrypted_text or "").strip()
# 兼容 BOM / NUL / 控制字符前缀
text = text.lstrip("\ufeff\x00\r\n\t ")
if not text:
raise ValueError("empty decrypted payload")
# 某些上游会把 JSON 再包一层字符串:"{\"UserId\":\"...\"}"
if text.startswith('"') and text.endswith('"'):
try:
unwrapped = json.loads(text)
if isinstance(unwrapped, str):
text = unwrapped.strip().lstrip("\ufeff\x00\r\n\t ")
except Exception:
pass
# 若前面夹杂少量杂字符,尝试定位第一个结构起始位
first_json = text.find("{")
first_xml = text.find("<")
starts = [x for x in [first_json, first_xml] if x >= 0]
if starts:
first = min(starts)
if first > 0:
text = text[first:]
if text.startswith("{"):
obj = json.loads(text)
obj["_payload_format"] = "json"
return obj
if text.startswith("<"):
root = ET.fromstring(text)
def get_text(tag: str, default: str = "") -> str:
node = root.find(tag)
return (node.text or "").strip() if node is not None and node.text is not None else default
content_node = root.find("content")
query = ""
if content_node is not None:
msg_node = content_node.find("msg")
if msg_node is not None and msg_node.text:
query = msg_node.text.strip()
return {
"UserId": get_text("userid", ""),
"Query": query,
"Timestamp": get_text("createtime", ""),
"MsgId": get_text("msgid", ""),
"ChannelId": get_text("appid", ""),
"appid": get_text("appid", ""),
"channel": get_text("channel", "0"),
"from": get_text("from", "0"),
"_payload_format": "xml",
}
# 兼容 querystring 明文UserId=xxx&Query=你好&appid=...
if "=" in text and "&" in text:
qs = parse_qs(text, keep_blank_values=True)
def qv(*keys: str) -> str:
for key in keys:
vals = qs.get(key) or qs.get(key.lower()) or qs.get(key.upper()) or []
if vals and str(vals[0]).strip():
return str(vals[0]).strip()
return ""
return {
"UserId": qv("UserId", "userid", "userId", "from_user"),
"Query": qv("Query", "query", "msg", "content"),
"Timestamp": qv("Timestamp", "timestamp", "createTime", "msg_time"),
"MsgId": qv("MsgId", "msgId", "msgid"),
"ChannelId": qv("ChannelId", "appid", "appId"),
"appid": qv("appid", "appId"),
"channel": qv("channel"),
"from": qv("from"),
"_payload_format": "querystring",
}
raise ValueError("unsupported payload format")
def _build_xml_callback_reply(data: dict, answer_text: str) -> dict:
user_id = str(data.get("UserId", "")).strip()
appid = str(data.get("ChannelId", "") or data.get("appid", "")).strip()
channel = int(str(data.get("channel", "0") or "0"))
msg = _build_keyword_h5_obj(str(data.get("Query", "")), user_id)
content = msg if msg else {"msg": answer_text}
return {"userid": user_id, "appid": appid, "content": content, "channel": channel, "from": 1}
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, 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 = _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) -> 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):
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) -> None:
try:
await _sync_user_profile_from_b(data)
except Exception:
logger.error("chatbot后台同步失败已忽略")
async def handle_business(data: dict) -> str:
user_id = str(data.get("UserId", "")).strip()
query = str(data.get("Query", "")).strip()
msg = _build_keyword_h5_obj(query, user_id)
if msg:
return json.dumps(msg, ensure_ascii=False)
return "1111111"
async def _handle_wechat_request(request: Request, app_id: Optional[str], background_tasks: BackgroundTasks) -> str:
if app_id and app_id != APP_ID:
logger.warning("chatbot app_id mismatch expected=%s actual=%s", APP_ID, app_id)
raw_body = (await request.body()).decode("utf-8", errors="ignore").strip()
if not raw_body:
raise HTTPException(status_code=400, detail="empty body")
cipher_b64 = raw_body
# 兼容三种请求体:
# 1) 纯密文 base64
# 2) JSON: {"encrypted":"..."} / {"encrypt":"..."} / {"Encrypt":"..."}
# 3) x-www-form-urlencoded: encrypted=... / encrypt=...
try:
outer = json.loads(raw_body)
if isinstance(outer, dict):
for key in ("encrypted", "encrypt", "Encrypt"):
val = outer.get(key)
if isinstance(val, str) and val.strip():
cipher_b64 = val
break
except Exception:
form = parse_qs(raw_body, keep_blank_values=True)
for key in ("encrypted", "encrypt", "Encrypt"):
vals = form.get(key) or []
if vals and str(vals[0]).strip():
cipher_b64 = str(vals[0])
break
cipher_b64 = "".join(str(cipher_b64).split()).strip()
try:
decrypted = aes_decrypt_base64(cipher_b64, ENCODING_AES_KEY)
logger.info(
"chatbot decrypted preview app_id=%s preview=%s",
app_id or "",
(decrypted or "")[:200],
)
data = _parse_decrypted_payload(decrypted)
logger.info(
"chatbot payload parsed app_id=%s format=%s user=%s query=%s",
app_id or "",
data.get("_payload_format", ""),
str(data.get("UserId", "") or data.get("userId", ""))[:80],
str(data.get("Query", ""))[:80],
)
except Exception as error:
logger.error(
"chatbot decrypt failed app_id=%s content_type=%s body_prefix=%s err=%s",
app_id or "",
request.headers.get("content-type", ""),
raw_body[:160],
error,
)
raise HTTPException(status_code=400, detail="decrypt error")
answer_text = await handle_business(data)
background_tasks.add_task(_safe_sync_user_profile_from_b, data)
if str(data.get("_payload_format", "")) == "xml":
resp_plain = json.dumps(_build_xml_callback_reply(data, answer_text), ensure_ascii=False)
response_app_id = str(data.get("ChannelId", "") or data.get("appid", "")).strip() or APP_ID
else:
resp_plain = json.dumps({"answer_type": "text", "text_info": {"short_answer": answer_text}}, ensure_ascii=False)
# 与原始 chatbot 服务保持一致thirdapiURL 带 app_id回包使用纯 AES不拼 app_id
response_app_id = None if app_id else APP_ID
return aes_encrypt_base64(resp_plain, ENCODING_AES_KEY, response_app_id)
@router.post("/", response_class=PlainTextResponse)
async def chatbot_root(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None):
return await _handle_wechat_request(request, app_id, background_tasks)
@router.post("/wechat/thirdapi", response_class=PlainTextResponse)
async def chatbot_thirdapi(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None):
return await _handle_wechat_request(request, app_id, background_tasks)
@router.get("/health")
def chatbot_health():
return {"status": "ok", "module": "chatbot-clone", "profile": ACTIVE_PROFILE}
@router.post("/api/private-message/query")
async def chatbot_private_message_query(payload: dict):
authtoken = str(payload.get("authtoken", "")).strip()
wxbot_bid = str(payload.get("wxbot_bid", "")).strip()
if not authtoken or not wxbot_bid:
raise HTTPException(status_code=400, detail="authtoken 和 wxbot_bid 不能为空")
result = await query_private_messages(
authtoken=authtoken,
wxbot_bid=wxbot_bid,
page=int(payload.get("page", 0)),
size=int(payload.get("size", 30)),
filter_value=int(payload.get("filter", 0)),
request_id=str(payload.get("requestid", "local-debug")).strip() or "local-debug",
)
contacts = result.get("contacts") or []
return {
"ok": True,
"count": result.get("count"),
"users": [
{
"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"),
"lastMsg": (c.get("lastMsg") or {}).get("content"),
"lastActiveTime": c.get("lastActiveTime"),
}
for c in contacts
],
"raw": result,
}
@router.post("/api/private-message/analyze")
async def chatbot_private_message_analyze(payload: dict):
authtoken = str(payload.get("authtoken", "")).strip()
if not authtoken:
raise HTTPException(status_code=400, detail="authtoken 不能为空")
try:
parts = authtoken.split(".")
payload_part = parts[1] if len(parts) == 3 else ""
payload_part += "=" * (-len(payload_part) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload_part.encode("utf-8")).decode("utf-8")) if payload_part else {}
except Exception:
claims = {}
now_ts = int(datetime.now(timezone.utc).timestamp())
exp = int(claims.get("exp", 0)) if str(claims.get("exp", "")).isdigit() else 0
expires_in_seconds = exp - now_ts if exp else None
status = "无法解析过期时间"
if isinstance(expires_in_seconds, int):
if expires_in_seconds <= 0:
status = "已过期"
elif expires_in_seconds < 3600:
status = "即将过期1小时内"
else:
status = "有效"
return {
"status": status,
"now_utc": datetime.now(timezone.utc).isoformat(),
"claims": claims,
"analysis": {
"expires_in_seconds": expires_in_seconds,
"expires_in_hours": round(expires_in_seconds / 3600, 2) if isinstance(expires_in_seconds, int) else None,
"summary": "该分析基于 JWT payload 解码,不包含签名有效性校验。",
},
}
@router.get("/private-message", response_class=HTMLResponse)
def chatbot_private_message_page():
return """
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chatbot 私信调试台</title>
<style>
body { font-family: system-ui, -apple-system, "Microsoft YaHei", sans-serif; margin: 0; padding: 20px; background: #0f172a; color: #e2e8f0; }
.wrap { max-width: 980px; margin: 0 auto; display: grid; gap: 12px; }
.card { background: #111827; border: 1px solid #334155; border-radius: 10px; padding: 14px; }
h2 { margin: 0 0 8px; }
label { display:block; margin: 8px 0 6px; font-size: 13px; color: #cbd5e1; }
input, textarea { width: 100%; border-radius: 8px; border: 1px solid #475569; background: #0b1220; color: #e2e8f0; padding: 10px; box-sizing: border-box; }
textarea { min-height: 88px; }
.row { display:grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.btns { display:flex; gap:10px; margin-top: 10px; }
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; }
#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; }
.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>
</head>
<body>
<div class="wrap">
<section class="card">
<h2>Chatbot 私信调试台</h2>
<div id="status">准备就绪</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>
<option value="xidu">异度世界</option>
</select>
<label>authtoken</label>
<textarea id="authtoken" placeholder="粘贴抓包 authtoken"></textarea>
<label>wxbot_bid</label>
<input id="wxbot_bid" placeholder="例如: 75021053883a9090c67d5becde7f4b0a" />
<div class="row">
<div>
<label>page</label>
<input id="page" value="0" />
</div>
<div>
<label>size</label>
<input id="size" value="30" />
</div>
</div>
<div class="btns">
<button onclick="queryPm()">查询私信</button>
<button class="alt" onclick="analyzeToken()">分析 Token</button>
</div>
</section>
<section class="card">
<h3 style="margin:0 0 8px;">用户列表</h3>
<div id="users"></div>
</section>
<section class="card">
<h3 style="margin:0 0 8px;">原始结果</h3>
<pre id="out">{}</pre>
</section>
</div>
<script>
const $ = (id) => document.getElementById(id);
const presets = {
eric: {
authtoken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4",
wxbot_bid: "75021053883a9090c67d5becde7f4b0a",
},
xidu: {
authtoken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4",
wxbot_bid: "4888c6ab5cb70dcc3adea1e2d2ff0201",
}
};
function setStatus(s){ $("status").textContent = s; }
function renderJson(v){ $("out").textContent = JSON.stringify(v, null, 2); }
function renderUsers(users){
const box = $("users");
if(!Array.isArray(users) || !users.length){
box.innerHTML = '<div class="u">暂无数据</div>';
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("");
}
function applyPreset(key){
const p = presets[key];
if(!p) return;
$("authtoken").value = p.authtoken;
$("wxbot_bid").value = p.wxbot_bid;
}
async function queryPm(){
try{
setStatus("查询中...");
const payload = {
authtoken: $("authtoken").value.trim(),
wxbot_bid: $("wxbot_bid").value.trim(),
page: Number($("page").value || 0),
size: Number($("size").value || 30),
};
const res = await fetch("/chatbot/api/private-message/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
setStatus(res.ok ? "查询成功" : "查询失败");
renderUsers(data.users || []);
renderJson(data);
}catch(e){
setStatus("请求异常");
renderJson({ error: String(e) });
}
}
async function analyzeToken(){
try{
setStatus("分析中...");
const payload = { authtoken: $("authtoken").value.trim() };
const res = await fetch("/chatbot/api/private-message/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
setStatus(res.ok ? "分析成功" : "分析失败");
renderJson(data);
}catch(e){
setStatus("请求异常");
renderJson({ error: String(e) });
}
}
$("preset").addEventListener("change", (e) => applyPreset(e.target.value));
applyPreset("eric");
</script>
</body>
</html>
"""