feat: 内置同步配置并增强回调匹配
将 PocketBase 与 B 接口同步参数改为代码内默认配置,避免部署时额外手动配置环境变量;同时补充回调幂等、用户匹配增强与调试接口,提升私信回调链路稳定性。 Made-with: Cursor
This commit is contained in:
13
config.py
13
config.py
@@ -1,3 +1,16 @@
|
||||
APP_ID = "fXYWvGfq04uMScP"
|
||||
TOKEN = "EWRotyMHfTLAlYmCHpdH8AZcuZPBHn"
|
||||
ENCODING_AES_KEY = "1wJZiJm8URfRjkHu1QfIVDeNFc2jbzzeCZqvhQecsCx"
|
||||
|
||||
# PocketBase(默认直连本机;与 payjsapi 项目保持同风格)
|
||||
PB_URL = "http://127.0.0.1:8090"
|
||||
PB_ADMIN_EMAIL = "xiaoshuang.eric@gmail.com"
|
||||
PB_ADMIN_PASSWORD = "Xiao4669805@"
|
||||
PB_COLLECTION = "wechat_private_users"
|
||||
|
||||
# A 回调触发 B 接口同步的默认配置
|
||||
PM_SYNC_AUTHTOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4"
|
||||
PM_SYNC_DEFAULT_WXBOT_BID = "4888c6ab5cb70dcc3adea1e2d2ff0201"
|
||||
PM_SYNC_BID_BY_CHANNEL = {
|
||||
"wx5cee92c2c93fb480": "4888c6ab5cb70dcc3adea1e2d2ff0201",
|
||||
}
|
||||
|
||||
528
main.py
528
main.py
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import base64
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
@@ -8,7 +9,18 @@ import httpx
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import PlainTextResponse, HTMLResponse
|
||||
|
||||
from config import APP_ID, TOKEN, ENCODING_AES_KEY
|
||||
from config import (
|
||||
APP_ID,
|
||||
TOKEN,
|
||||
ENCODING_AES_KEY,
|
||||
PB_URL,
|
||||
PB_ADMIN_EMAIL,
|
||||
PB_ADMIN_PASSWORD,
|
||||
PB_COLLECTION,
|
||||
PM_SYNC_AUTHTOKEN,
|
||||
PM_SYNC_DEFAULT_WXBOT_BID,
|
||||
PM_SYNC_BID_BY_CHANNEL,
|
||||
)
|
||||
from crypto_utils import aes_decrypt_base64, aes_encrypt_base64, calc_signature
|
||||
|
||||
|
||||
@@ -17,6 +29,294 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="WeChat Dialog ThirdAPI Demo")
|
||||
|
||||
_processed_callback_cache: dict[str, int] = {}
|
||||
|
||||
|
||||
def _build_keyword_h5_reply(query: str, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
按关键词返回 H5 卡片(answer/short_answer 需为 JSON 字符串)。
|
||||
"""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return None
|
||||
|
||||
if "电子书" in q:
|
||||
msg = {
|
||||
"news": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "数字游民",
|
||||
"description": "地理套利与自动化杠杆",
|
||||
"url": f"https://www.hackrobot.cn?userid={user_id}",
|
||||
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
|
||||
"type": "h5",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return json.dumps(msg, ensure_ascii=False)
|
||||
|
||||
if "群" in q:
|
||||
msg = {
|
||||
"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 json.dumps(msg, ensure_ascii=False)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _safe_filter_value(value: str) -> str:
|
||||
return (value or "").replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def _to_int(value) -> Optional[int]:
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_text(value) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _build_callback_dedupe_key(data: dict) -> str:
|
||||
userid = _normalize_text(data.get("UserId") or data.get("userId"))
|
||||
msg_id = _normalize_text(data.get("MsgId") or data.get("MessageId") or data.get("msgId"))
|
||||
ts = _normalize_text(data.get("Timestamp") or data.get("MessageTime") or data.get("msgTime"))
|
||||
query = _normalize_text(data.get("Query"))
|
||||
return "|".join([userid, msg_id, ts, query])
|
||||
|
||||
|
||||
def _is_duplicate_callback(data: dict, ttl_seconds: int = 3600) -> bool:
|
||||
"""
|
||||
回调幂等(进程内):同 userid/msg_id/time/query 在 TTL 内仅处理一次同步逻辑。
|
||||
不影响主回复。
|
||||
"""
|
||||
now_ts = int(time.time())
|
||||
# 清理过期 key,避免缓存无限增长
|
||||
expired = [k for k, exp in _processed_callback_cache.items() if exp <= now_ts]
|
||||
for k in expired:
|
||||
_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
|
||||
|
||||
|
||||
def _extract_b_last_msg(contact: dict) -> dict:
|
||||
return (contact.get("lastMsg") or {}) if isinstance(contact, dict) else {}
|
||||
|
||||
|
||||
def _pick_best_contact_match(contacts: list, target_userid: str, source_query: str, source_msg_id: str, source_msg_time) -> Optional[dict]:
|
||||
"""
|
||||
在同 userid 的候选中做二次匹配:
|
||||
- msg_id 一致优先
|
||||
- 文本一致次优
|
||||
- 时间最接近再次优先
|
||||
"""
|
||||
if not target_userid:
|
||||
return None
|
||||
|
||||
same_user = [
|
||||
c for c in contacts
|
||||
if _normalize_text(c.get("userId") or c.get("userid")) == target_userid
|
||||
]
|
||||
if not same_user:
|
||||
return None
|
||||
|
||||
query_norm = _normalize_text(source_query)
|
||||
source_msg_id_norm = _normalize_text(source_msg_id)
|
||||
src_ts = _to_int(source_msg_time)
|
||||
|
||||
def score(contact: dict) -> tuple[int, int, int]:
|
||||
last_msg = _extract_b_last_msg(contact)
|
||||
last_msg_id = _normalize_text(last_msg.get("msgId") or last_msg.get("id"))
|
||||
last_content = _normalize_text(last_msg.get("content"))
|
||||
last_active = _to_int(contact.get("lastActiveTime"))
|
||||
|
||||
msg_id_hit = 1 if source_msg_id_norm and last_msg_id and source_msg_id_norm == last_msg_id else 0
|
||||
content_hit = 1 if query_norm and last_content and query_norm == last_content else 0
|
||||
# 时间越接近分越高(负值用于降序)
|
||||
if src_ts is None or last_active is None:
|
||||
time_delta = 10**12
|
||||
else:
|
||||
time_delta = abs(src_ts - last_active)
|
||||
return (msg_id_hit, content_hit, -time_delta)
|
||||
|
||||
return sorted(same_user, key=score, reverse=True)[0]
|
||||
|
||||
|
||||
async def _pb_upsert_user_profile(record: dict) -> None:
|
||||
"""
|
||||
使用 PocketBase Admin API upsert 用户资料。
|
||||
需要环境变量:
|
||||
- PB_URL
|
||||
- PB_ADMIN_EMAIL
|
||||
- PB_ADMIN_PASSWORD
|
||||
可选:
|
||||
- PB_COLLECTION(默认 wechat_private_users)
|
||||
"""
|
||||
pb_url = (PB_URL or "").rstrip("/")
|
||||
pb_admin_email = PB_ADMIN_EMAIL or ""
|
||||
pb_admin_password = PB_ADMIN_PASSWORD or ""
|
||||
pb_collection = PB_COLLECTION or "wechat_private_users"
|
||||
if not pb_url or not pb_admin_email or not pb_admin_password:
|
||||
logger.warning("PocketBase 配置不完整,跳过资料同步")
|
||||
return
|
||||
|
||||
userid = str(record.get("userid", "")).strip()
|
||||
channel_id = str(record.get("channel_id", "")).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},
|
||||
)
|
||||
auth_resp.raise_for_status()
|
||||
token = (auth_resp.json() or {}).get("token", "")
|
||||
if not token:
|
||||
logger.warning("PocketBase 登录无 token,跳过资料同步")
|
||||
return
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
user_expr = _safe_filter_value(userid)
|
||||
chan_expr = _safe_filter_value(channel_id)
|
||||
filter_exp = f'userid="{user_expr}" && channel_id="{chan_expr}"' if channel_id else f'userid="{user_expr}"'
|
||||
list_resp = await client.get(
|
||||
f"{pb_url}/api/collections/{pb_collection}/records",
|
||||
params={"page": 1, "perPage": 1, "filter": filter_exp, "sort": "-updated"},
|
||||
headers=headers,
|
||||
)
|
||||
list_resp.raise_for_status()
|
||||
items = ((list_resp.json() or {}).get("items")) or []
|
||||
|
||||
if items:
|
||||
rid = items[0].get("id")
|
||||
if rid:
|
||||
patch_resp = await client.patch(
|
||||
f"{pb_url}/api/collections/{pb_collection}/records/{rid}",
|
||||
json=record,
|
||||
headers=headers,
|
||||
)
|
||||
patch_resp.raise_for_status()
|
||||
return
|
||||
|
||||
create_resp = await client.post(
|
||||
f"{pb_url}/api/collections/{pb_collection}/records",
|
||||
json=record,
|
||||
headers=headers,
|
||||
)
|
||||
create_resp.raise_for_status()
|
||||
|
||||
|
||||
async def _sync_user_profile_from_b(data: dict) -> None:
|
||||
"""
|
||||
A 接口收到消息后,立即调用 B 接口拉取会话并同步用户资料到 PocketBase。
|
||||
环境变量:
|
||||
- PM_SYNC_AUTHTOKEN(必填)
|
||||
- PM_SYNC_DEFAULT_WXBOT_BID(可选)
|
||||
- PM_SYNC_BID_BY_CHANNEL_JSON(可选,形如 {"wx5cee...":"4888..."})
|
||||
"""
|
||||
authtoken = (PM_SYNC_AUTHTOKEN or "").strip()
|
||||
if not authtoken:
|
||||
logger.info("未配置 PM_SYNC_AUTHTOKEN,跳过 B 接口同步")
|
||||
return
|
||||
|
||||
channel_id = str(data.get("ChannelId", "") or data.get("channelId", "")).strip()
|
||||
bid_map = PM_SYNC_BID_BY_CHANNEL if isinstance(PM_SYNC_BID_BY_CHANNEL, dict) else {}
|
||||
bid_map = {str(k): str(v) for k, v in bid_map.items()}
|
||||
|
||||
wxbot_bid = bid_map.get(channel_id) or (PM_SYNC_DEFAULT_WXBOT_BID or "").strip()
|
||||
if not wxbot_bid:
|
||||
logger.warning("未配置可用 wxbot_bid,跳过 B 接口同步")
|
||||
return
|
||||
|
||||
target_userid = str(data.get("UserId", "") or data.get("userId", "")).strip()
|
||||
if not target_userid:
|
||||
return
|
||||
|
||||
if _is_duplicate_callback(data):
|
||||
logger.info("命中回调幂等缓存,跳过重复同步:userid=%s", target_userid)
|
||||
return
|
||||
|
||||
msg_id = str(data.get("MsgId", "") or data.get("MessageId", "") or data.get("msgId", "")).strip()
|
||||
msg_time = data.get("Timestamp", "") or data.get("MessageTime", "") or data.get("msgTime", "")
|
||||
query = str(data.get("Query", "")).strip()
|
||||
|
||||
b_resp = await query_private_messages(
|
||||
authtoken=authtoken,
|
||||
wxbot_bid=wxbot_bid,
|
||||
page=0,
|
||||
size=100,
|
||||
filter_value=0,
|
||||
request_id=f"thirdapi-{target_userid}",
|
||||
)
|
||||
contacts = b_resp.get("contacts") or []
|
||||
target = _pick_best_contact_match(
|
||||
contacts=contacts,
|
||||
target_userid=target_userid,
|
||||
source_query=query,
|
||||
source_msg_id=msg_id,
|
||||
source_msg_time=msg_time,
|
||||
)
|
||||
if not target:
|
||||
logger.info("B 接口未匹配到用户:%s", target_userid)
|
||||
return
|
||||
|
||||
last_msg = target.get("lastMsg") or {}
|
||||
record = {
|
||||
"userid": target_userid,
|
||||
"channel_id": channel_id,
|
||||
"nick": target.get("nick", ""),
|
||||
"avatar": target.get("headImg") or target.get("avatar") or target.get("headimgurl") or target.get("headImgUrl") or "",
|
||||
"source_query": query,
|
||||
"source_message_id": msg_id,
|
||||
"source_message_time": str(msg_time),
|
||||
"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),
|
||||
}
|
||||
await _pb_upsert_user_profile(record)
|
||||
|
||||
|
||||
@app.post("/debug/sync-test")
|
||||
async def debug_sync_test(payload: dict):
|
||||
"""
|
||||
手动调试 A->B->PocketBase 同步链路。
|
||||
body 示例:
|
||||
{
|
||||
"UserId": "...",
|
||||
"ChannelId": "...",
|
||||
"Query": "电子书",
|
||||
"MsgId": "...",
|
||||
"Timestamp": 1774933444
|
||||
}
|
||||
"""
|
||||
await _sync_user_profile_from_b(payload or {})
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "sync triggered",
|
||||
"dedupe_key": _build_callback_dedupe_key(payload or {}),
|
||||
}
|
||||
|
||||
|
||||
async def handle_business(data: dict) -> str:
|
||||
"""
|
||||
@@ -39,22 +339,10 @@ async def handle_business(data: dict) -> str:
|
||||
# ]
|
||||
# return ";".join(parts)
|
||||
|
||||
# 当前示例:返回一个 H5 富文本结构(平台会对 short_answer 做 JSON.parse)
|
||||
h5_msg = {
|
||||
"news": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "实时更新:新型肺炎疫情最新动态",
|
||||
"description": "腾讯新闻第一时间同步全国新型肺炎疫情动态,欢迎关注、转发",
|
||||
"url": "https://news.qq.com/zt2020/page/feiyan.htm",
|
||||
"picurl": "http://mmbiz.qpic.cn/mmbiz_jpg/W3gQtpV3j8D8kZRqfpTJlfVqubwgFQf47H0GWlGV6leaDF80ZpdtuFhQVsCsM3YKmwkujXzdjR2k6aWfA41ic7Q/0?wx_fmt=jpeg",
|
||||
"type": "h5",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
# 按文档要求,answer/short_answer 是字符串,内部再 JSON.parse 才是对象
|
||||
return json.dumps(h5_msg, ensure_ascii=False)
|
||||
keyword_reply = _build_keyword_h5_reply(query, user_id)
|
||||
if keyword_reply:
|
||||
return keyword_reply
|
||||
return "已收到你的消息,输入“电子书”或“群”可获取对应卡片。"
|
||||
|
||||
|
||||
def _decode_jwt_payload_without_verify(token: str) -> dict:
|
||||
@@ -187,6 +475,13 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str]) -> str
|
||||
logger.exception("签名校验异常(忽略或按需处理)")
|
||||
|
||||
# 7. 根据业务逻辑构造应答
|
||||
# 7.1 先做用户资料同步(异常不影响主回复)
|
||||
try:
|
||||
await _sync_user_profile_from_b(data)
|
||||
except Exception:
|
||||
logger.exception("触发 B 接口同步到 PocketBase 失败(不影响回复)")
|
||||
|
||||
# 7.2 再生成业务回复
|
||||
try:
|
||||
answer_text = await handle_business(data)
|
||||
except Exception:
|
||||
@@ -314,7 +609,7 @@ def private_message_page():
|
||||
color: #cbd5e1;
|
||||
font-size: 13px;
|
||||
}
|
||||
textarea, input {
|
||||
textarea, input, select {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
@@ -325,7 +620,7 @@ def private_message_page():
|
||||
outline: none;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
}
|
||||
textarea:focus, input:focus {
|
||||
textarea:focus, input:focus, select:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.15);
|
||||
}
|
||||
@@ -362,6 +657,87 @@ def private_message_page():
|
||||
color: var(--ok);
|
||||
}
|
||||
.status.warn { color: var(--warn); }
|
||||
.panel-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
color: #dbeafe;
|
||||
}
|
||||
.token-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.token-kpi {
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.token-kpi .label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.token-kpi .value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #bfdbfe;
|
||||
}
|
||||
.user-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
padding: 10px;
|
||||
}
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
.avatar-placeholder {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(99, 102, 241, 0.35);
|
||||
color: #e0e7ff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
.user-meta {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.user-nick {
|
||||
font-size: 14px;
|
||||
color: #f1f5f9;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.user-id {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
background: #090d18;
|
||||
@@ -375,6 +751,7 @@ def private_message_page():
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.row { grid-template-columns: 1fr; }
|
||||
.token-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -383,6 +760,8 @@ def private_message_page():
|
||||
<section class="card">
|
||||
<h2>私信查询调试台</h2>
|
||||
<p class="sub">用于调试微信对话开放平台私信列表接口,支持一键查询、Token 过期分析与结果可视化。</p>
|
||||
<label>默认配置</label>
|
||||
<select id="presetSelect"></select>
|
||||
<label>authtoken</label>
|
||||
<textarea id="authtoken" rows="4" placeholder="粘贴 Charles 抓包中的 authtoken"></textarea>
|
||||
<label>wxbot_bid</label>
|
||||
@@ -406,6 +785,20 @@ def private_message_page():
|
||||
|
||||
<section class="card">
|
||||
<p id="status" class="status">等待操作...</p>
|
||||
<h3 class="panel-title">Token 过期信息</h3>
|
||||
<div id="tokenReport" class="token-grid">
|
||||
<div class="token-kpi">
|
||||
<div class="label">状态</div>
|
||||
<div id="tokenStatus" class="value">-</div>
|
||||
</div>
|
||||
<div class="token-kpi">
|
||||
<div class="label">剩余时间</div>
|
||||
<div id="tokenRemain" class="value">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="panel-title" style="margin-top: 12px;">用户列表(userid / 头像 / 昵称)</h3>
|
||||
<div id="userList" class="user-list"></div>
|
||||
<h3 class="panel-title" style="margin-top: 12px;">原始返回</h3>
|
||||
<pre id="output">{
|
||||
"message": "点击上方按钮开始查询"
|
||||
}</pre>
|
||||
@@ -413,6 +806,47 @@ def private_message_page():
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const defaultPresets = [
|
||||
{
|
||||
id: 'xidu-shijie',
|
||||
name: '异度世界',
|
||||
authtoken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4',
|
||||
wxbot_bid: '4888c6ab5cb70dcc3adea1e2d2ff0201',
|
||||
},
|
||||
{
|
||||
id: 'eric-travel',
|
||||
name: 'Eric在旅居',
|
||||
authtoken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4',
|
||||
wxbot_bid: '75021053883a9090c67d5becde7f4b0a',
|
||||
},
|
||||
];
|
||||
|
||||
function initPresetSelector() {
|
||||
const select = document.getElementById('presetSelect');
|
||||
select.innerHTML = defaultPresets
|
||||
.map((item) => `<option value="${item.id}">${item.name}</option>`)
|
||||
.join('');
|
||||
select.addEventListener('change', () => {
|
||||
const preset = defaultPresets.find((x) => x.id === select.value);
|
||||
if (!preset) return;
|
||||
document.getElementById('authtoken').value = preset.authtoken;
|
||||
document.getElementById('wxbot_bid').value = preset.wxbot_bid;
|
||||
});
|
||||
if (defaultPresets.length > 0) {
|
||||
select.value = defaultPresets[0].id;
|
||||
select.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
function updatePresetAuthtoken() {
|
||||
const select = document.getElementById('presetSelect');
|
||||
const selectedId = select.value;
|
||||
const idx = defaultPresets.findIndex((x) => x.id === selectedId);
|
||||
if (idx >= 0) {
|
||||
defaultPresets[idx].authtoken = document.getElementById('authtoken').value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(loading, text) {
|
||||
document.getElementById('queryBtn').disabled = loading;
|
||||
document.getElementById('analyzeBtn').disabled = loading;
|
||||
@@ -426,7 +860,47 @@ def private_message_page():
|
||||
document.getElementById('output').textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
function formatRemain(seconds) {
|
||||
if (typeof seconds !== 'number') return '-';
|
||||
if (seconds <= 0) return '已过期';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
return `${days}天 ${hours}小时 ${mins}分钟`;
|
||||
}
|
||||
|
||||
function renderTokenReport(report) {
|
||||
const statusEl = document.getElementById('tokenStatus');
|
||||
const remainEl = document.getElementById('tokenRemain');
|
||||
const statusText = report?.status || '-';
|
||||
const remainText = formatRemain(report?.analysis?.expires_in_seconds);
|
||||
statusEl.textContent = statusText;
|
||||
remainEl.textContent = remainText;
|
||||
statusEl.style.color = statusText.includes('过期') ? '#fca5a5' : '#86efac';
|
||||
remainEl.style.color = remainText === '已过期' ? '#fca5a5' : '#bfdbfe';
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
const list = document.getElementById('userList');
|
||||
if (!Array.isArray(users) || users.length === 0) {
|
||||
list.innerHTML = '<div class="hint">当前暂无用户数据,先点击“查询私信”。</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = users.map((u) => {
|
||||
const nick = u.nick || '未命名用户';
|
||||
const uid = u.userId || '未知 userid';
|
||||
const avatar = u.avatar || '';
|
||||
const safeNick = String(nick).replace(/</g, '<').replace(/>/g, '>');
|
||||
const safeUid = String(uid).replace(/</g, '<').replace(/>/g, '>');
|
||||
const avatarHtml = avatar
|
||||
? `<img class="avatar" src="${avatar}" alt="${safeNick}" referrerpolicy="no-referrer" />`
|
||||
: `<div class="avatar-placeholder">${safeNick.slice(0, 1)}</div>`;
|
||||
return `<div class="user-item">${avatarHtml}<div class="user-meta"><div class="user-nick">${safeNick}</div><div class="user-id">${safeUid}</div></div></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function queryPm() {
|
||||
updatePresetAuthtoken();
|
||||
const payload = {
|
||||
authtoken: document.getElementById('authtoken').value.trim(),
|
||||
wxbot_bid: document.getElementById('wxbot_bid').value.trim(),
|
||||
@@ -447,6 +921,7 @@ def private_message_page():
|
||||
} else {
|
||||
document.getElementById('status').textContent = '查询成功';
|
||||
}
|
||||
renderUsers(data.users);
|
||||
render(data);
|
||||
} catch (err) {
|
||||
document.getElementById('status').classList.add('warn');
|
||||
@@ -458,6 +933,7 @@ def private_message_page():
|
||||
}
|
||||
|
||||
async function analyzeToken() {
|
||||
updatePresetAuthtoken();
|
||||
const payload = {
|
||||
authtoken: document.getElementById('authtoken').value.trim(),
|
||||
};
|
||||
@@ -475,6 +951,7 @@ def private_message_page():
|
||||
} else {
|
||||
document.getElementById('status').textContent = '分析成功';
|
||||
}
|
||||
renderTokenReport(data);
|
||||
render(data);
|
||||
} catch (err) {
|
||||
document.getElementById('status').classList.add('warn');
|
||||
@@ -484,6 +961,9 @@ def private_message_page():
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
initPresetSelector();
|
||||
renderUsers([]);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -522,6 +1002,16 @@ async def api_private_message_query(payload: dict):
|
||||
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
|
||||
],
|
||||
"contacts_preview": [
|
||||
{
|
||||
"nick": c.get("nick"),
|
||||
|
||||
Reference in New Issue
Block a user