Files
gitlab-instance-0a899031_pa…/app/routers/chatbot.py
2026-04-07 00:31:43 -05:00

954 lines
37 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 asyncio
import base64
import json
import logging
import os
import struct
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from typing import NamedTuple, Optional
from urllib.parse import parse_qs, quote
import httpx
from Crypto.Cipher import AES
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from ..services.chatbot_miniprogram_card import build_miniprogram_card_msg, normalize_miniprogrampage_msg
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__)
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"]
HACKROBOT_PROFILE = "xidu"
def _resolve_profile_key(profile_key: str) -> str:
k = (profile_key or "").strip().lower()
return k if k in OPEN_API_PROFILES else "eric"
def _profile_creds(profile_key: str) -> dict:
return OPEN_API_PROFILES[_resolve_profile_key(profile_key)]
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")
class SharedChatbotReply(NamedTuple):
"""全公众号配置共用的解析结果H5 图文、小程序卡片,或兜底纯文本(含超链)。"""
h5_message: Optional[dict]
"""与「发送客服消息」H5 一致:{\"news\": {\"articles\": [...]}};关键词为 Query 子串包含命中,未命中时为 None。"""
text_fallback: str
"""未命中关键词时的 short_answer 文案(可含 <a href>)。"""
miniprogram_message: Optional[dict]
"""与「发送客服消息」小程序卡片一致:{\"miniprogrampage\": {...}};未命中「小程序卡片」时为 None。"""
def _normalize_h5_msg_per_wechat_doc(h5_message: dict) -> dict:
"""
与文档「发送客服消息」H5 一致msg = { news: { articles: [ { title, description, url, picurl, type } ] } }。
仅保留这五个字段,避免多余键导致客户端不渲染。
"""
raw = h5_message.get("news") if isinstance(h5_message, dict) else None
arts = raw.get("articles") if isinstance(raw, dict) else None
if not isinstance(arts, list):
arts = []
norm_articles: list[dict] = []
for a in arts:
if not isinstance(a, dict):
continue
t = str(a.get("type", "h5") or "h5").strip().lower()
if t not in ("h5", "mp"):
t = "h5"
norm_articles.append(
{
"title": str(a.get("title", "")),
"description": str(a.get("description", "")),
"url": str(a.get("url", "")),
"picurl": str(a.get("picurl", "")),
"type": t,
}
)
return {"news": {"articles": norm_articles}}
def shared_build_h5_keyword_message(query: str, user_id: str, profile_key: str) -> Optional[dict]:
"""关键词命中时返回文档约定的 H5 msg再经 _normalize 输出)。
命中规则:对用户 Query 首尾 trim 后,**包含**配置词作为连续子串即命中(子串匹配),
不要求用户整句与关键词全等。群卡片统一按「群」子串命中。
「小程序卡片」在 shared_resolve_reply 中优先处理,不在此函数返回。
"""
q = (query or "").strip()
uid = (user_id or "").strip()
if "电子书" in q:
return {
"news": {
"articles": [
{
"title": "数字游民",
"description": "地理套利与自动化杠杆",
"url": f"https://vip.hackrobot.cn/ebook?userid={uid}",
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
"type": "h5",
}
]
}
}
group_hit = "" in q
if group_hit:
return {
"news": {
"articles": [
{
"title": "异度星球",
"description": "电子书、视频教程、私密社群",
"url": f"https://vip.hackrobot.cn?userid={uid}",
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
"type": "h5",
}
]
}
}
return None
def build_fallback_reply_plain_text(user_id: str) -> str:
"""
兜底回复(单独构建):未命中「小程序卡片」「电子书」「群」等关键词时使用。
/chatbot、/hackrobot 共用同一文案与链接逻辑,仅解密密钥因 profile 不同。
"""
uid = (user_id or "").strip()
href = f"https://vip.hackrobot.cn/?userid={quote(uid, safe='')}" if uid else "https://vip.hackrobot.cn/"
return "🤖消息已收到,稍晚看到后回复\n参与交流,👉可<a href=\"" + href + "\">加群</a>"
def shared_resolve_reply(data: dict, profile_key: str) -> SharedChatbotReply:
"""根据解密后的 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()
if "小程序卡片" in query:
mp = build_miniprogram_card_msg(user_id=user_id)
return SharedChatbotReply(None, "", mp)
h5 = shared_build_h5_keyword_message(query, user_id, profile_key)
if h5:
return SharedChatbotReply(h5, "", None)
return SharedChatbotReply(None, build_fallback_reply_plain_text(user_id), None)
def shared_build_openapi_text_answer(short_answer: str) -> dict:
"""开放 API 文档「输出」:文本 / 含超链兜底。"""
return {"answer_type": "text", "text_info": {"short_answer": short_answer}}
def _xml_channel_h5_envelope(data: dict, h5_message: dict) -> dict:
"""XML 入站(如 Eric /chatbot命中关键词userid 信封 + content.news与线上一致。"""
user_id = str(data.get("UserId", "") or data.get("userId", "") or "").strip()
appid = str(data.get("ChannelId", "") or data.get("appid", "")).strip()
channel = int(str(data.get("channel", "0") or "0"))
return {"userid": user_id, "appid": appid, "content": h5_message, "channel": channel, "from": 1}
def _openapi_json_h5_answer_per_sendmsg_doc(msg: dict) -> dict:
"""
开放 API JSON 入站命中 H5 / 小程序卡片:与文档「发送客服消息」一致。
msg 为 { news: { articles: [...] } } 或 { miniprogrampage: {...} }。
第三方回调侧将该 msg 序列化为 JSON 字符串,放入 answer_type=text 的 text_info.short_answer
(与客服接口里 msg 对象同源结构,由网关解析为卡片)。
"""
compact = json.dumps(msg, ensure_ascii=False, separators=(",", ":"))
return {"answer_type": "text", "text_info": {"short_answer": compact}}
def _openapi_json_merge_request_echo(data: dict, body: dict) -> dict:
"""回传 RequestId便于开放平台把应答与请求关联避免未命中/空白展示)。"""
rid = data.get("RequestId")
if rid is None:
return body
s = str(rid).strip()
if not s:
return body
out = dict(body)
out["RequestId"] = rid
return out
def shared_build_plain_body_for_wechat_callback(
*,
payload_format: str,
reply: SharedChatbotReply,
data: dict,
) -> dict:
"""
回调加密前的明文 JSON 统一入口(关键词 H5 与兜底共用一套解析结果 reply
- /chatbot 多为 XML 入站H5 用 userid 信封;兜底改用 answer_type=text信封里 content.msg 对超链异常)。
- /hackrobot 多为 JSON 入站:兜底用 answer_type=textH5 用同结构的 msg JSON 串作为 short_answer见发送客服消息文档
"""
fmt = (payload_format or "").strip().lower()
is_xml = fmt == "xml"
if reply.miniprogram_message:
msg = normalize_miniprogrampage_msg(reply.miniprogram_message)
if is_xml:
return _xml_channel_h5_envelope(data, msg)
body = _openapi_json_h5_answer_per_sendmsg_doc(msg)
return _openapi_json_merge_request_echo(data, body)
if reply.h5_message:
msg = _normalize_h5_msg_per_wechat_doc(reply.h5_message)
if is_xml:
return _xml_channel_h5_envelope(data, msg)
body = _openapi_json_h5_answer_per_sendmsg_doc(msg)
return _openapi_json_merge_request_echo(data, body)
body = shared_build_openapi_text_answer(reply.text_fallback)
if not is_xml:
body = _openapi_json_merge_request_echo(data, body)
return body
def _ensure_user_id_from_session(data: dict) -> None:
"""开放 API JSON 可能只带 SessionId如 0_xxx|openid补全 UserId 供关键词链接与兜底文案使用。"""
uid = str(data.get("UserId", "") or data.get("userId", "") or "").strip()
if uid:
return
sid = str(data.get("SessionId", "") or "").strip()
if "|" in sid:
tail = sid.rsplit("|", 1)[-1].strip()
if tail:
data["UserId"] = tail
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")
async def _handle_wechat_request(
request: Request,
app_id: Optional[str],
background_tasks: BackgroundTasks,
*,
profile_key: str,
) -> str:
creds = _profile_creds(profile_key)
expected_app_id = creds["APP_ID"]
encoding_key = creds["ENCODING_AES_KEY"]
if app_id and app_id != expected_app_id:
logger.warning(
"chatbot app_id mismatch profile=%s expected=%s actual=%s",
profile_key,
expected_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_key)
logger.info(
"chatbot decrypted preview profile=%s app_id=%s preview=%s",
profile_key,
app_id or "",
(decrypted or "")[:200],
)
data = _parse_decrypted_payload(decrypted)
_ensure_user_id_from_session(data)
logger.info(
"chatbot payload parsed profile=%s app_id=%s format=%s user=%s query=%s",
profile_key,
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 profile=%s app_id=%s content_type=%s body_prefix=%s err=%s",
profile_key,
app_id or "",
request.headers.get("content-type", ""),
raw_body[:160],
error,
)
raise HTTPException(status_code=400, detail="decrypt error")
reply = shared_resolve_reply(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)
resp_plain = json.dumps(plain_obj, ensure_ascii=False)
logger.info(
"chatbot reply_plain profile=%s fmt=%s shape=%s",
profile_key,
fmt,
list(plain_obj.keys()),
)
if fmt == "xml":
response_app_id = str(data.get("ChannelId", "") or data.get("appid", "")).strip() or expected_app_id
else:
# 与原始 chatbot 服务保持一致thirdapiURL 带 app_id回包使用纯 AES不拼 app_id
response_app_id = None if app_id else expected_app_id
return aes_encrypt_base64(resp_plain, encoding_key, response_app_id)
@router.post("/chatbot", response_class=PlainTextResponse)
@router.post("/chatbot/", 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, profile_key=ACTIVE_PROFILE)
@router.post("/chatbot/wechat/thirdapi", response_class=PlainTextResponse)
@router.post("/chatbot/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, profile_key=ACTIVE_PROFILE)
@router.get("/chatbot/health")
@router.get("/chatbot/health/")
def chatbot_health():
return {"status": "ok", "module": "chatbot-clone", "profile": ACTIVE_PROFILE}
hackrobot_router = APIRouter(tags=["hackrobot"])
@hackrobot_router.post("/hackrobot", response_class=PlainTextResponse)
@hackrobot_router.post("/hackrobot/", response_class=PlainTextResponse)
async def hackrobot_root(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None):
return await _handle_wechat_request(request, app_id, background_tasks, profile_key=HACKROBOT_PROFILE)
@hackrobot_router.post("/hackrobot/wechat/thirdapi", response_class=PlainTextResponse)
@hackrobot_router.post("/hackrobot/wechat/thirdapi/", response_class=PlainTextResponse)
async def hackrobot_thirdapi(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None):
return await _handle_wechat_request(request, app_id, background_tasks, profile_key=HACKROBOT_PROFILE)
@hackrobot_router.get("/hackrobot/health")
@hackrobot_router.get("/hackrobot/health/")
def hackrobot_health():
return {"status": "ok", "module": "chatbot-clone", "profile": HACKROBOT_PROFILE}
def _log_like_incoming(request: Request, body: bytes) -> None:
"""SmsForwarder Webhook 调试:打印完整入参(公众号助手等通知转发)。"""
client_host = request.client.host if request.client else None
client_port = request.client.port if request.client else None
query = {k: v for k, v in request.query_params.multi_items()}
headers = {k: v for k, v in request.headers.items()}
ct = headers.get("content-type", "") or headers.get("Content-Type", "")
logger.info(
"[like] ----- incoming POST ----- path=%s full_url=%s client=%s:%s query=%r",
request.url.path,
str(request.url),
client_host,
client_port,
query,
)
logger.info("[like] content_type=%r content_length=%s body_bytes=%s", ct, headers.get("content-length"), len(body))
logger.info("[like] headers=%s", headers)
if not body:
logger.info("[like] body=(empty)")
return
text = None
try:
text = body.decode("utf-8")
except UnicodeDecodeError:
hex_preview = body[:400].hex()
suffix = "..." if len(body) > 400 else ""
logger.info("[like] body=(non-utf8, hex prefix) %s%s", hex_preview, suffix)
return
logger.info("[like] body_text=\n%s", text)
low = (ct or "").lower()
if "json" in low:
try:
parsed = json.loads(text)
logger.info("[like] body_json=%s", json.dumps(parsed, ensure_ascii=False, indent=2))
except json.JSONDecodeError as e:
logger.info("[like] body_json_parse_failed: %s", e)
def _client_ip_for_like(request: Request) -> str:
xff = request.headers.get("x-forwarded-for") or request.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
if request.client:
return request.client.host or ""
return ""
@router.post("/chatbot/like")
@hackrobot_router.post("/hackrobot/like")
async def like_webhook_sink(request: Request):
"""接收 SmsForwarder 等 Webhook POST详细打日志公众号助手通知写入 PocketBase。"""
body = await request.body()
_log_like_incoming(request, body)
ct = request.headers.get("content-type", "") or ""
payload: dict = {"ok": True, "received_bytes": len(body)}
try:
text = body.decode("utf-8")
except UnicodeDecodeError:
return JSONResponse(status_code=200, content=payload)
if is_mp_recommend_webhook_body(text, ct):
try:
summary = await asyncio.to_thread(try_persist_from_like_body, text, ct, _client_ip_for_like(request))
if summary:
payload["mp_recommend"] = summary
except Exception:
logger.exception("[like] PocketBase 写入 mp_recommend 失败")
payload["mp_recommend_error"] = "persist_failed"
return JSONResponse(status_code=200, content=payload)
@router.post("/chatbot/api/private-message/query")
@hackrobot_router.post("/hackrobot/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": contact_avatar_url(c) or None,
"lastMsg": (c.get("lastMsg") or {}).get("content"),
"lastActiveTime": c.get("lastActiveTime"),
}
for c in contacts
],
"raw": result,
}
@router.post("/chatbot/api/private-message/analyze")
@hackrobot_router.post("/hackrobot/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 解码,不包含签名有效性校验。",
},
}
def _private_message_debug_html(api_prefix: str) -> str:
base = """
<!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; }
#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; 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>
</head>
<body>
<div class="wrap">
<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>
<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); }
/** 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){
const empty = document.createElement("div");
empty.className = "u";
empty.textContent = "暂无数据";
box.appendChild(empty);
return;
}
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){
const p = presets[key];
if(!p) return;
$("authtoken").value = p.authtoken;
$("wxbot_bid").value = p.wxbot_bid;
startTokenCountdown();
}
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("__API_PREFIX__/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("__API_PREFIX__/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);
startTokenCountdown();
}catch(e){
setStatus("请求异常");
renderJson({ error: String(e) });
}
}
$("preset").addEventListener("change", (e) => applyPreset(e.target.value));
$("authtoken").addEventListener("input", function(){ startTokenCountdown(); });
applyPreset("eric");
</script>
</body>
</html>
"""
return base.replace("__API_PREFIX__", (api_prefix or "").rstrip("/"))
@router.get("/chatbot/private-message")
@router.get("/chatbot/private-message/")
@hackrobot_router.get("/hackrobot/private-message")
@hackrobot_router.get("/hackrobot/private-message/")
def chatbot_private_message_page(request: Request):
api_prefix = "/hackrobot" if str(request.url.path).startswith("/hackrobot") else "/chatbot"
return HTMLResponse(_private_message_debug_html(api_prefix))