'init'
This commit is contained in:
BIN
__pycache__/config.cpython-312.pyc
Normal file
BIN
__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/crypto_utils.cpython-312.pyc
Normal file
BIN
__pycache__/crypto_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/main.cpython-312.pyc
Normal file
BIN
__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
3
config.py
Normal file
3
config.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
APP_ID = "fXYWvGfq04uMScP"
|
||||||
|
TOKEN = "EWRotyMHfTLAlYmCHpdH8AZcuZPBHn"
|
||||||
|
ENCODING_AES_KEY = "1wJZiJm8URfRjkHu1QfIVDeNFc2jbzzeCZqvhQecsCx"
|
||||||
62
crypto_utils.py
Normal file
62
crypto_utils.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
|
|
||||||
|
BLOCK_SIZE = 32 # PKCS5/PKCS7 padding 使用 32 字节块大小
|
||||||
|
|
||||||
|
|
||||||
|
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 = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
|
||||||
|
return data + bytes([amount_to_pad]) * amount_to_pad
|
||||||
|
|
||||||
|
|
||||||
|
def get_aes_key_and_iv(encoding_aes_key: str) -> Tuple[bytes, bytes]:
|
||||||
|
"""
|
||||||
|
encoding_aes_key 是平台给的 43 位字符串,需要先补一个 '=' 再 base64 解码。
|
||||||
|
得到 32 字节 key,前 16 字节为 iv。
|
||||||
|
"""
|
||||||
|
aes_key = base64.b64decode(encoding_aes_key + "=")
|
||||||
|
if len(aes_key) != 32:
|
||||||
|
raise ValueError("encodingAESKey 无效,解码后长度不是 32 字节")
|
||||||
|
iv = aes_key[:16]
|
||||||
|
return aes_key, iv
|
||||||
|
|
||||||
|
|
||||||
|
def aes_decrypt_base64(cipher_b64: str, encoding_aes_key: str) -> str:
|
||||||
|
aes_key, iv = get_aes_key_and_iv(encoding_aes_key)
|
||||||
|
cipher_bytes = base64.b64decode(cipher_b64)
|
||||||
|
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
||||||
|
decrypted = cipher.decrypt(cipher_bytes)
|
||||||
|
decrypted = pkcs5_unpadding(decrypted)
|
||||||
|
return decrypted.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def aes_encrypt_base64(plaintext: str, encoding_aes_key: str) -> str:
|
||||||
|
aes_key, iv = get_aes_key_and_iv(encoding_aes_key)
|
||||||
|
data = plaintext.encode("utf-8")
|
||||||
|
padded = pkcs5_padding(data)
|
||||||
|
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
||||||
|
encrypted = cipher.encrypt(padded)
|
||||||
|
return base64.b64encode(encrypted).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def calc_signature(token: str, timestamp: int, skill_name: str, intent_name: str, query: str) -> str:
|
||||||
|
"""
|
||||||
|
文档签名算法:
|
||||||
|
str = token + time.Now.Unix + skill_name + intent_name + query
|
||||||
|
signature = MD5(str) 小写 hex
|
||||||
|
参考:https://developers.weixin.qq.com/doc/aispeech/confapi/thirdapi/thirdapi.html
|
||||||
|
"""
|
||||||
|
s = f"{token}{timestamp}{skill_name}{intent_name}{query}"
|
||||||
|
return hashlib.md5(s.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
577
main.py
Normal file
577
main.py
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import base64
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
|
from fastapi.responses import PlainTextResponse, HTMLResponse
|
||||||
|
|
||||||
|
from config import APP_ID, TOKEN, ENCODING_AES_KEY
|
||||||
|
from crypto_utils import aes_decrypt_base64, aes_encrypt_base64, calc_signature
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(title="WeChat Dialog ThirdAPI Demo")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_business(data: dict) -> str:
|
||||||
|
"""
|
||||||
|
业务处理钩子:
|
||||||
|
- data 为解密后的请求 JSON(XwBrainThirdApiReqInfo 扩展结构)
|
||||||
|
- 返回值为要展示给用户的短文本答案
|
||||||
|
后续如果要接数据库、HTTP 接口,只需要改这里的逻辑即可。
|
||||||
|
"""
|
||||||
|
query = data.get("Query", "")
|
||||||
|
skill_name = data.get("SkillName", "")
|
||||||
|
intent_name = data.get("IntentName", "")
|
||||||
|
user_id = data.get("UserId", "")
|
||||||
|
|
||||||
|
# 如果想返回普通文本,可以使用下面的示例文案:
|
||||||
|
# parts = [
|
||||||
|
# f"技能:{skill_name or '未提供'}",
|
||||||
|
# f"意图:{intent_name or '未提供'}",
|
||||||
|
# f"用户ID:{user_id or '未提供'}",
|
||||||
|
# f"用户问题:{query or '空'}",
|
||||||
|
# ]
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_jwt_payload_without_verify(token: str) -> dict:
|
||||||
|
"""
|
||||||
|
仅用于本地调试分析 JWT 的 payload(不做签名校验)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) != 3:
|
||||||
|
return {}
|
||||||
|
payload = parts[1]
|
||||||
|
payload += "=" * (-len(payload) % 4) # base64url 补齐
|
||||||
|
data = base64.urlsafe_b64decode(payload.encode("utf-8")).decode("utf-8")
|
||||||
|
obj = json.loads(data)
|
||||||
|
return obj if isinstance(obj, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""
|
||||||
|
转发请求到微信对话开放平台的私信列表接口(你在 Charles 抓包的接口)。
|
||||||
|
"""
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
# 调试场景下常见抓包证书/公司代理证书导致校验失败,这里关闭 verify 以保证接口可用。
|
||||||
|
# 生产环境建议改为 verify=True 并配置受信任证书链。
|
||||||
|
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 _handle_wechat_request(request: Request, app_id: Optional[str]) -> str:
|
||||||
|
"""
|
||||||
|
公共处理逻辑:便于同时支持 "/" 和 "/wechat/thirdapi" 两种回调路径。
|
||||||
|
"""
|
||||||
|
# 1. app_id 校验
|
||||||
|
# 说明:有些平台配置下不会显式在 URL 带 app_id,这种情况下我们只做日志,不强制拦截,避免 400。
|
||||||
|
if app_id is None:
|
||||||
|
logger.warning("请求中未携带 app_id(将继续处理,仅做告警)")
|
||||||
|
elif app_id != APP_ID:
|
||||||
|
logger.warning(f"app_id 不匹配,期望={APP_ID}, 实际={app_id}(将继续处理,仅做告警)")
|
||||||
|
|
||||||
|
# 2. 获取原始 body(加密后的 base64 字符串)
|
||||||
|
raw_body_bytes = await request.body()
|
||||||
|
cipher_b64 = raw_body_bytes.decode("utf-8").strip()
|
||||||
|
if not cipher_b64:
|
||||||
|
raise HTTPException(status_code=400, detail="empty body")
|
||||||
|
|
||||||
|
# 打印原始 body,便于对照平台文档排查(是否真的是 base64 + AES 加密数据)
|
||||||
|
logger.info(f"收到原始 body(未解密,前 500 字符):{cipher_b64[:500]}")
|
||||||
|
|
||||||
|
# 平台当前实际发送的是 {"encrypted":"..."} 这一层 JSON,需要先解析再取字段
|
||||||
|
if cipher_b64.startswith("{"):
|
||||||
|
try:
|
||||||
|
outer = json.loads(cipher_b64)
|
||||||
|
if isinstance(outer, dict) and "encrypted" in outer:
|
||||||
|
cipher_b64 = str(outer["encrypted"]).replace("\n", "").strip()
|
||||||
|
except Exception:
|
||||||
|
# 如果解析失败,就按原始字符串继续处理(保持兼容)
|
||||||
|
logger.warning("外层 JSON 解析失败,按原始 body 作为密文处理")
|
||||||
|
|
||||||
|
# 3. 解密
|
||||||
|
try:
|
||||||
|
decrypted_json_str = aes_decrypt_base64(cipher_b64, ENCODING_AES_KEY)
|
||||||
|
except Exception:
|
||||||
|
# 常见情况:非第三方服务接口的探测/噪音流量,或使用了不同的 AESKey。
|
||||||
|
logger.error(
|
||||||
|
"AES 解密失败,可能为非本机器人第三方服务请求或密钥不匹配(忽略该请求)。"
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=400, detail="decrypt error")
|
||||||
|
|
||||||
|
# 4. 解析 JSON
|
||||||
|
try:
|
||||||
|
data = json.loads(decrypted_json_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error(f"解密结果不是合法 JSON: {decrypted_json_str}")
|
||||||
|
raise HTTPException(status_code=400, detail="invalid json in decrypted body")
|
||||||
|
|
||||||
|
# 5. 打印请求内容到终端日志(分析、显示,带中文注释)
|
||||||
|
logger.info("收到微信第三方服务请求(已解密):")
|
||||||
|
logger.info(
|
||||||
|
"请求概览:RequestId=%s,用户ID=%s,问题=%s,技能=%s,意图=%s",
|
||||||
|
data.get("RequestId", ""),
|
||||||
|
data.get("UserId", ""),
|
||||||
|
data.get("Query", ""),
|
||||||
|
data.get("SkillName", ""),
|
||||||
|
data.get("IntentName", ""),
|
||||||
|
)
|
||||||
|
logger.info("完整请求JSON:%s", json.dumps(data, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
# 6. (可选)签名校验
|
||||||
|
try:
|
||||||
|
timestamp = data.get("Timestamp")
|
||||||
|
skill_name = data.get("SkillName", "")
|
||||||
|
intent_name = data.get("IntentName", "")
|
||||||
|
query = data.get("Query", "")
|
||||||
|
provided_sig = data.get("Signature", "")
|
||||||
|
|
||||||
|
if timestamp is not None and provided_sig:
|
||||||
|
expected_sig = calc_signature(TOKEN, timestamp, skill_name, intent_name, query)
|
||||||
|
if expected_sig != provided_sig:
|
||||||
|
logger.warning(
|
||||||
|
f"签名校验失败,expected={expected_sig}, provided={provided_sig}"
|
||||||
|
)
|
||||||
|
# 这里直接拒绝请求,你也可以根据需要改成仅告警
|
||||||
|
raise HTTPException(status_code=400, detail="invalid signature")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("签名校验异常(忽略或按需处理)")
|
||||||
|
|
||||||
|
# 7. 根据业务逻辑构造应答
|
||||||
|
try:
|
||||||
|
answer_text = await handle_business(data)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("业务处理异常,将返回兜底文案")
|
||||||
|
answer_text = "系统繁忙,请稍后再试。"
|
||||||
|
|
||||||
|
resp_obj = {
|
||||||
|
# 文本类型,内容为可被 JSON.parse 的 H5 结构字符串
|
||||||
|
"answer_type": "text",
|
||||||
|
"text_info": {
|
||||||
|
"short_answer": answer_text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp_json_str = json.dumps(resp_obj, ensure_ascii=False)
|
||||||
|
|
||||||
|
# 8. 加密响应 JSON
|
||||||
|
try:
|
||||||
|
encrypted_resp = aes_encrypt_base64(resp_json_str, ENCODING_AES_KEY)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("响应 AES 加密失败")
|
||||||
|
raise HTTPException(status_code=500, detail="encrypt error") from e
|
||||||
|
|
||||||
|
# 按文档要求:如果配置了加密,这里返回的是加密后的 base64 字符串
|
||||||
|
return encrypted_resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/", response_class=PlainTextResponse)
|
||||||
|
async def wechat_root(request: Request, app_id: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
有些配置示例直接写根路径,例如:
|
||||||
|
url: http://example.com/
|
||||||
|
这种情况下,微信会 POST 到 "/",所以这里也做同样处理。
|
||||||
|
"""
|
||||||
|
return await _handle_wechat_request(request, app_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/wechat/thirdapi", response_class=PlainTextResponse)
|
||||||
|
async def wechat_thirdapi(request: Request, app_id: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
显式的 /wechat/thirdapi 路径,同样处理。
|
||||||
|
"""
|
||||||
|
return await _handle_wechat_request(request, app_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health_check():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/debug/echo")
|
||||||
|
async def debug_echo(request: Request, app_id: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
调试用接口:回显请求头和 app_id,方便排查签名和路由问题。
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"app_id": app_id,
|
||||||
|
"headers": dict(request.headers),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/private-message", response_class=HTMLResponse)
|
||||||
|
def 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>私信查询</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1020;
|
||||||
|
--card: rgba(17, 24, 39, 0.78);
|
||||||
|
--card-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--text: #e5e7eb;
|
||||||
|
--muted: #9ca3af;
|
||||||
|
--primary: #60a5fa;
|
||||||
|
--primary-2: #3b82f6;
|
||||||
|
--ok: #34d399;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 800px at 0% 0%, #1e3a8a 0%, rgba(30,58,138,0) 40%),
|
||||||
|
radial-gradient(1000px 600px at 100% 20%, #4338ca 0%, rgba(67,56,202,0) 35%),
|
||||||
|
var(--bg);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 28px 14px 40px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1060px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin: 8px 0 6px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
textarea, input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .2s, box-shadow .2s;
|
||||||
|
}
|
||||||
|
textarea:focus, input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.15);
|
||||||
|
}
|
||||||
|
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-2));
|
||||||
|
transition: transform .08s ease, opacity .2s;
|
||||||
|
}
|
||||||
|
button.secondary {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||||
|
}
|
||||||
|
button:active { transform: translateY(1px); }
|
||||||
|
.hint {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.status.warn { color: var(--warn); }
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
background: #090d18;
|
||||||
|
color: #d1fae5;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 520px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<section class="card">
|
||||||
|
<h2>私信查询调试台</h2>
|
||||||
|
<p class="sub">用于调试微信对话开放平台私信列表接口,支持一键查询、Token 过期分析与结果可视化。</p>
|
||||||
|
<label>authtoken</label>
|
||||||
|
<textarea id="authtoken" rows="4" placeholder="粘贴 Charles 抓包中的 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="actions">
|
||||||
|
<button id="queryBtn" onclick="queryPm()">查询私信</button>
|
||||||
|
<button id="analyzeBtn" class="secondary" onclick="analyzeToken()">分析 Token 过期时间</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">提示:查询失败时,先检查 authtoken 是否过期、wxbot_bid 是否与当前机器人一致。</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<p id="status" class="status">等待操作...</p>
|
||||||
|
<pre id="output">{
|
||||||
|
"message": "点击上方按钮开始查询"
|
||||||
|
}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function setLoading(loading, text) {
|
||||||
|
document.getElementById('queryBtn').disabled = loading;
|
||||||
|
document.getElementById('analyzeBtn').disabled = loading;
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
status.classList.remove('warn');
|
||||||
|
status.textContent = text || (loading ? '请求中...' : '完成');
|
||||||
|
if (loading) status.classList.add('warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
document.getElementById('output').textContent = JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryPm() {
|
||||||
|
const payload = {
|
||||||
|
authtoken: document.getElementById('authtoken').value.trim(),
|
||||||
|
wxbot_bid: document.getElementById('wxbot_bid').value.trim(),
|
||||||
|
page: Number(document.getElementById('page').value || 0),
|
||||||
|
size: Number(document.getElementById('size').value || 30),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
setLoading(true, '正在查询私信...');
|
||||||
|
const res = await fetch('/api/private-message/query', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
document.getElementById('status').classList.add('warn');
|
||||||
|
document.getElementById('status').textContent = '查询失败';
|
||||||
|
} else {
|
||||||
|
document.getElementById('status').textContent = '查询成功';
|
||||||
|
}
|
||||||
|
render(data);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('status').classList.add('warn');
|
||||||
|
document.getElementById('status').textContent = '请求异常';
|
||||||
|
render({ error: String(err) });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyzeToken() {
|
||||||
|
const payload = {
|
||||||
|
authtoken: document.getElementById('authtoken').value.trim(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
setLoading(true, '正在分析 Token...');
|
||||||
|
const res = await fetch('/api/private-message/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
document.getElementById('status').classList.add('warn');
|
||||||
|
document.getElementById('status').textContent = '分析失败';
|
||||||
|
} else {
|
||||||
|
document.getElementById('status').textContent = '分析成功';
|
||||||
|
}
|
||||||
|
render(data);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('status').classList.add('warn');
|
||||||
|
document.getElementById('status').textContent = '请求异常';
|
||||||
|
render({ error: String(err) });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/private-message/query")
|
||||||
|
async def api_private_message_query(payload: dict):
|
||||||
|
"""
|
||||||
|
查询私信列表接口(调试版)。
|
||||||
|
"""
|
||||||
|
authtoken = str(payload.get("authtoken", "")).strip()
|
||||||
|
wxbot_bid = str(payload.get("wxbot_bid", "")).strip()
|
||||||
|
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"
|
||||||
|
|
||||||
|
if not authtoken or not wxbot_bid:
|
||||||
|
raise HTTPException(status_code=400, detail="authtoken 和 wxbot_bid 不能为空")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await query_private_messages(
|
||||||
|
authtoken=authtoken,
|
||||||
|
wxbot_bid=wxbot_bid,
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
filter_value=filter_value,
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("私信查询失败:%s", str(e))
|
||||||
|
raise HTTPException(status_code=502, detail=f"private-message query failed: {e}")
|
||||||
|
|
||||||
|
contacts = result.get("contacts") or []
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"count": result.get("count"),
|
||||||
|
"contacts_preview": [
|
||||||
|
{
|
||||||
|
"nick": c.get("nick"),
|
||||||
|
"userId": c.get("userId"),
|
||||||
|
"lastMsg": (c.get("lastMsg") or {}).get("content"),
|
||||||
|
"lastActiveTime": c.get("lastActiveTime"),
|
||||||
|
}
|
||||||
|
for c in contacts
|
||||||
|
],
|
||||||
|
"raw": result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/private-message/analyze")
|
||||||
|
async def api_private_message_analyze(payload: dict):
|
||||||
|
"""
|
||||||
|
分析 authtoken 的过期时间和基本信息(基于 JWT payload,不校验签名)。
|
||||||
|
"""
|
||||||
|
authtoken = str(payload.get("authtoken", "")).strip()
|
||||||
|
if not authtoken:
|
||||||
|
raise HTTPException(status_code=400, detail="authtoken 不能为空")
|
||||||
|
|
||||||
|
claims = _decode_jwt_payload_without_verify(authtoken)
|
||||||
|
now_ts = int(datetime.now(timezone.utc).timestamp())
|
||||||
|
exp = int(claims.get("exp", 0)) if str(claims.get("exp", "")).isdigit() else 0
|
||||||
|
iat = int(claims.get("iat", 0)) if str(claims.get("iat", "")).isdigit() else 0
|
||||||
|
sign_time_ms = claims.get("signetime")
|
||||||
|
|
||||||
|
expires_in_seconds = exp - now_ts if exp else None
|
||||||
|
if expires_in_seconds is None:
|
||||||
|
status = "无法解析过期时间"
|
||||||
|
elif expires_in_seconds <= 0:
|
||||||
|
status = "已过期"
|
||||||
|
elif expires_in_seconds < 3600:
|
||||||
|
status = "即将过期(1小时内)"
|
||||||
|
else:
|
||||||
|
status = "有效"
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"status": status,
|
||||||
|
"now_utc": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"claims": claims,
|
||||||
|
"analysis": {
|
||||||
|
"iat_utc": datetime.fromtimestamp(iat, tz=timezone.utc).isoformat() if iat else None,
|
||||||
|
"exp_utc": datetime.fromtimestamp(exp, tz=timezone.utc).isoformat() if exp else None,
|
||||||
|
"expires_in_seconds": expires_in_seconds,
|
||||||
|
"expires_in_hours": round(expires_in_seconds / 3600, 2) if isinstance(expires_in_seconds, int) else None,
|
||||||
|
"signetime_ms": sign_time_ms,
|
||||||
|
"summary": "该分析基于 JWT payload 解码,不包含签名有效性校验。",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
pycryptodome
|
||||||
|
httpx
|
||||||
6
run.py
Normal file
6
run.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import uvicorn
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 如果 8000 被其他程序占用,可以改成 8001
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=False)
|
||||||
53
token_client.py
Normal file
53
token_client.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import APP_ID, TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN_URL = "https://openaiapi.weixin.qq.com/v2/token"
|
||||||
|
|
||||||
|
|
||||||
|
def _md5_hex(data: str) -> str:
|
||||||
|
return hashlib.md5(data.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_access_token(account: Optional[str] = None) -> str:
|
||||||
|
"""
|
||||||
|
调用微信智能对话 /v2/token 接口,换取 AccessToken。
|
||||||
|
文档参考:https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html
|
||||||
|
"""
|
||||||
|
timestamp = int(time.time())
|
||||||
|
nonce = uuid.uuid4().hex[:16]
|
||||||
|
|
||||||
|
body_obj = {}
|
||||||
|
if account:
|
||||||
|
body_obj["account"] = account
|
||||||
|
body_str = json.dumps(body_obj, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
|
# sign = md5(Token + str(unix_timestamp) + nonce + md5(body))
|
||||||
|
body_md5 = _md5_hex(body_str) if body_str else _md5_hex("")
|
||||||
|
sign_src = f"{TOKEN}{timestamp}{nonce}{body_md5}"
|
||||||
|
sign = _md5_hex(sign_src)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"X-APPID": APP_ID,
|
||||||
|
"request_id": str(uuid.uuid4()),
|
||||||
|
"timestamp": str(timestamp),
|
||||||
|
"nonce": nonce,
|
||||||
|
"sign": sign,
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
resp = await client.post(TOKEN_URL, headers=headers, content=body_str.encode("utf-8"))
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise RuntimeError(f"fetch_access_token error: {data}")
|
||||||
|
return data["data"]["access_token"]
|
||||||
|
|
||||||
Reference in New Issue
Block a user