feat: 内置同步配置并增强回调匹配

将 PocketBase 与 B 接口同步参数改为代码内默认配置,避免部署时额外手动配置环境变量;同时补充回调幂等、用户匹配增强与调试接口,提升私信回调链路稳定性。

Made-with: Cursor
This commit is contained in:
eric
2026-03-31 10:29:12 -05:00
parent 1f7894c768
commit 256ba2c52b
2 changed files with 522 additions and 19 deletions

View File

@@ -1,3 +1,16 @@
APP_ID = "fXYWvGfq04uMScP" APP_ID = "fXYWvGfq04uMScP"
TOKEN = "EWRotyMHfTLAlYmCHpdH8AZcuZPBHn" TOKEN = "EWRotyMHfTLAlYmCHpdH8AZcuZPBHn"
ENCODING_AES_KEY = "1wJZiJm8URfRjkHu1QfIVDeNFc2jbzzeCZqvhQecsCx" 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
View File

@@ -1,6 +1,7 @@
import json import json
import logging import logging
import base64 import base64
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
@@ -8,7 +9,18 @@ import httpx
from fastapi import FastAPI, Request, HTTPException from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import PlainTextResponse, HTMLResponse 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 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") 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: async def handle_business(data: dict) -> str:
""" """
@@ -39,22 +339,10 @@ async def handle_business(data: dict) -> str:
# ] # ]
# return "".join(parts) # return "".join(parts)
# 当前示例:返回一个 H5 富文本结构(平台会对 short_answer 做 JSON.parse keyword_reply = _build_keyword_h5_reply(query, user_id)
h5_msg = { if keyword_reply:
"news": { return keyword_reply
"articles": [ return "已收到你的消息,输入“电子书”或“群”可获取对应卡片。"
{
"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)
def _decode_jwt_payload_without_verify(token: str) -> dict: 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("签名校验异常(忽略或按需处理)") logger.exception("签名校验异常(忽略或按需处理)")
# 7. 根据业务逻辑构造应答 # 7. 根据业务逻辑构造应答
# 7.1 先做用户资料同步(异常不影响主回复)
try:
await _sync_user_profile_from_b(data)
except Exception:
logger.exception("触发 B 接口同步到 PocketBase 失败(不影响回复)")
# 7.2 再生成业务回复
try: try:
answer_text = await handle_business(data) answer_text = await handle_business(data)
except Exception: except Exception:
@@ -314,7 +609,7 @@ def private_message_page():
color: #cbd5e1; color: #cbd5e1;
font-size: 13px; font-size: 13px;
} }
textarea, input { textarea, input, select {
width: 100%; width: 100%;
border: 1px solid rgba(148, 163, 184, 0.35); border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.75); background: rgba(15, 23, 42, 0.75);
@@ -325,7 +620,7 @@ def private_message_page():
outline: none; outline: none;
transition: border-color .2s, box-shadow .2s; transition: border-color .2s, box-shadow .2s;
} }
textarea:focus, input:focus { textarea:focus, input:focus, select:focus {
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.15); box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.15);
} }
@@ -362,6 +657,87 @@ def private_message_page():
color: var(--ok); color: var(--ok);
} }
.status.warn { color: var(--warn); } .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 { pre {
margin: 0; margin: 0;
background: #090d18; background: #090d18;
@@ -375,6 +751,7 @@ def private_message_page():
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.row { grid-template-columns: 1fr; } .row { grid-template-columns: 1fr; }
.token-grid { grid-template-columns: 1fr; }
} }
</style> </style>
</head> </head>
@@ -383,6 +760,8 @@ def private_message_page():
<section class="card"> <section class="card">
<h2>私信查询调试台</h2> <h2>私信查询调试台</h2>
<p class="sub">用于调试微信对话开放平台私信列表接口支持一键查询、Token 过期分析与结果可视化。</p> <p class="sub">用于调试微信对话开放平台私信列表接口支持一键查询、Token 过期分析与结果可视化。</p>
<label>默认配置</label>
<select id="presetSelect"></select>
<label>authtoken</label> <label>authtoken</label>
<textarea id="authtoken" rows="4" placeholder="粘贴 Charles 抓包中的 authtoken"></textarea> <textarea id="authtoken" rows="4" placeholder="粘贴 Charles 抓包中的 authtoken"></textarea>
<label>wxbot_bid</label> <label>wxbot_bid</label>
@@ -406,6 +785,20 @@ def private_message_page():
<section class="card"> <section class="card">
<p id="status" class="status">等待操作...</p> <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">{ <pre id="output">{
"message": "点击上方按钮开始查询" "message": "点击上方按钮开始查询"
}</pre> }</pre>
@@ -413,6 +806,47 @@ def private_message_page():
</div> </div>
<script> <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) { function setLoading(loading, text) {
document.getElementById('queryBtn').disabled = loading; document.getElementById('queryBtn').disabled = loading;
document.getElementById('analyzeBtn').disabled = loading; document.getElementById('analyzeBtn').disabled = loading;
@@ -426,7 +860,47 @@ def private_message_page():
document.getElementById('output').textContent = JSON.stringify(data, null, 2); 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, '&lt;').replace(/>/g, '&gt;');
const safeUid = String(uid).replace(/</g, '&lt;').replace(/>/g, '&gt;');
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() { async function queryPm() {
updatePresetAuthtoken();
const payload = { const payload = {
authtoken: document.getElementById('authtoken').value.trim(), authtoken: document.getElementById('authtoken').value.trim(),
wxbot_bid: document.getElementById('wxbot_bid').value.trim(), wxbot_bid: document.getElementById('wxbot_bid').value.trim(),
@@ -447,6 +921,7 @@ def private_message_page():
} else { } else {
document.getElementById('status').textContent = '查询成功'; document.getElementById('status').textContent = '查询成功';
} }
renderUsers(data.users);
render(data); render(data);
} catch (err) { } catch (err) {
document.getElementById('status').classList.add('warn'); document.getElementById('status').classList.add('warn');
@@ -458,6 +933,7 @@ def private_message_page():
} }
async function analyzeToken() { async function analyzeToken() {
updatePresetAuthtoken();
const payload = { const payload = {
authtoken: document.getElementById('authtoken').value.trim(), authtoken: document.getElementById('authtoken').value.trim(),
}; };
@@ -475,6 +951,7 @@ def private_message_page():
} else { } else {
document.getElementById('status').textContent = '分析成功'; document.getElementById('status').textContent = '分析成功';
} }
renderTokenReport(data);
render(data); render(data);
} catch (err) { } catch (err) {
document.getElementById('status').classList.add('warn'); document.getElementById('status').classList.add('warn');
@@ -484,6 +961,9 @@ def private_message_page():
setLoading(false); setLoading(false);
} }
} }
initPresetSelector();
renderUsers([]);
</script> </script>
</body> </body>
</html> </html>
@@ -522,6 +1002,16 @@ async def api_private_message_query(payload: dict):
return { return {
"ok": True, "ok": True,
"count": result.get("count"), "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": [ "contacts_preview": [
{ {
"nick": c.get("nick"), "nick": c.get("nick"),