docs/examples: 扫码登录改为官方 POST+local_token_list+iLink-App-Id/ClientVersion 头 + 完整状态机 + 换码竞态警告

This commit is contained in:
AskaEth committed 2026-09-08 02:46:07 +08:00
1 parent 38b776705d
commit 8a36915377
6 files changed
+566 -96

No files matched your search

+139 -25
View File
@@ -37,12 +37,17 @@ const saveState = () =>
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
// ---------- iLink HTTP 封装 ----------
// 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token 时不带 Authorization,
// 但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。
function headers() {
const uin = Buffer.from(String(randomBytes(4).readUInt32BE(0))).toString("base64");
return {
"Content-Type": "application/json",
AuthorizationType: "ilink_bot_token",
"X-WECHAT-UIN": uin,
"iLink-App-Id": "bot",
// 2.4.8 -> (2&0xff)<<16 | (4&0xff)<<8 | (8&0xff) = 132104(数字字符串,非 "1")
"iLink-App-ClientVersion": "132104",
...(state.bot_token ? { Authorization: `Bearer ${state.bot_token}` } : {}),
};
}
@@ -58,29 +63,86 @@ async function apiPost(path, body, timeoutMs = 45_000) {
}
// ---------- 1. 扫码登录(一次性) ----------
async function login() {
const qr = await fetch(`${BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3`)
.then((r) => r.json());
// 接口1: POST /ilink/bot/get_bot_qrcode(官方姿势:body={"local_token_list":[]},非裸 GET)
async function refreshQR() {
const qr = await fetch(`${BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3`, {
method: "POST",
headers: headers(),
body: JSON.stringify({ local_token_list: state.bot_token ? [state.bot_token] : [] }),
}).then((r) => r.json());
console.log("请用微信扫码:", qr.qrcode_img_content);
// 终端显示二维码: npm i qrcode-terminal,然后 qrcode.generate(qr.qrcode_img_content, {small:true})
// qrcode_img_content 是 liteapp 跳转 URL,须作为二维码内容编码展示,勿把 qr.qrcode 当二维码
// 终端显示: npm i qrcode-terminal,然后 qrcode.generate(qr.qrcode_img_content, {small:true})
return qr.qrcode;
}
async function login() {
// 状态机: wait / scaned / scaned_but_redirect / need_verifycode /
// verify_code_blocked / binded_redirect / confirmed / expired
// 两大坑(务必读 docs/02 §4.5):
// ① 码服务端 TTL≈60s,生成后须在窗口内扫(超窗服务端自返回 expired,此处换新码)。
// ② 「换码竞态」:一旦收到 scaned(或 scaned_but_redirect / need_verifycode),
// 绝不能再换码——换码会砍掉"扫码→确认"会话,微信端显示成功但后端永远收不到 confirmed。
let base = BASE_URL;
let qrcode = await refreshQR();
let verifyCode = null;
const deadline = Date.now() + 8 * 60_000;
while (Date.now() < deadline) {
const s = await fetch(
`${BASE_URL}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qr.qrcode)}`,
{ headers: { "iLink-App-ClientVersion": "1" } }
).then((r) => r.json());
let ep = `${base}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
if (verifyCode) ep += `&verify_code=${encodeURIComponent(verifyCode)}`;
const s = await fetch(ep, { headers: headers() }).then((r) => r.json());
if (s.status === "confirmed") {
state.bot_token = s.bot_token;
state.bot_id = s.ilink_bot_id;
state.owner_id = s.ilink_user_id;
saveState();
console.log("登录成功, bot:", s.ilink_bot_id);
return;
switch (s.status) {
case "wait":
process.stdout.write(`\r等待扫码... status=${s.status} `);
await new Promise((r) => setTimeout(r, 1000));
continue;
case "scaned":
verifyCode = null;
console.log("\n已扫码,请在手机上确认...(此后绝不换码,避免砍掉确认会话)");
await new Promise((r) => setTimeout(r, 1000));
continue;
case "scaned_but_redirect":
verifyCode = null;
if (s.redirect_host) {
base = `https://${s.redirect_host}`;
console.log("IDC 重定向,切换轮询至", base);
}
await new Promise((r) => setTimeout(r, 1000));
continue;
case "need_verifycode": {
const { createInterface } = await import("node:readline");
const rl = createInterface({ input: process.stdin, output: process.stdout });
verifyCode = await new Promise((r) => rl.question("请在手机微信上输入显示的数字后回车: ", r));
rl.close();
continue;
}
case "verify_code_blocked":
verifyCode = null;
console.log("\n配对码多次输入错误,重新获取二维码");
qrcode = await refreshQR();
continue;
case "binded_redirect":
console.log("该 bot 已绑定过本实例,视为连接成功");
return;
case "confirmed":
state.bot_token = s.bot_token;
state.bot_id = s.ilink_bot_id;
state.owner_id = s.ilink_user_id;
state.baseurl = s.baseurl;
saveState();
console.log("登录成功, bot:", s.ilink_bot_id);
return;
case "expired":
verifyCode = null;
console.log("\n二维码已过期,重新获取");
qrcode = await refreshQR();
continue;
default:
console.log("\n未知状态:", s.status);
await new Promise((r) => setTimeout(r, 1000));
continue;
}
process.stdout.write(`\r等待扫码... status=${s.status} `);
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error("扫码超时,请重试");
}
@@ -203,11 +265,16 @@ def save_state():
os.chmod(STATE_FILE, 0o600)
# ---------- iLink HTTP 封装 ----------
# 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token 时不带 Authorization,
# 但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。
def headers():
h = {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": base64.b64encode(str(random.getrandbits(32)).encode()).decode(),
"iLink-App-Id": "bot",
# 2.4.8 -> (2&0xff)<<16 | (4&0xff)<<8 | (8&0xff) = 132104(数字字符串,非 "1")
"iLink-App-ClientVersion": "132104",
}
if state["bot_token"]:
h["Authorization"] = f"Bearer {state['bot_token']}"
@@ -218,22 +285,69 @@ def api_post(path, body, timeout=45):
return r.json()
# ---------- 1. 扫码登录(一次性) ----------
# 接口1: POST /ilink/bot/get_bot_qrcode(官方姿势:body={"local_token_list":[]},非裸 GET)
def fetch_qrcode():
return requests.post(
f"{BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3",
headers=headers(), json={"local_token_list": []}, timeout=15,
).json()
def login():
qr = requests.get(f"{BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3").json()
# 状态机: wait / scaned / scaned_but_redirect / need_verifycode /
# verify_code_blocked / binded_redirect / confirmed / expired
# 两大坑(务必读 docs/02 §4.5):
# ① 码服务端 TTL≈60s,生成后须在窗口内扫(超窗服务端自返回 expired,此处换新码)。
# ② 「换码竞态」:一旦收到 scaned(或 scaned_but_redirect / need_verifycode),
# 绝不能再换码——换码会砍掉"扫码→确认"会话,微信端显示成功但后端永远收不到 confirmed。
base_url = BASE_URL
qr = fetch_qrcode()
qrcode = qr["qrcode"]
print("请用微信扫码:", qr["qrcode_img_content"])
# qrcode_img_content 是 liteapp 跳转 URL,须作为二维码内容编码展示,勿把 qr["qrcode"] 当二维码
deadline = time.time() + 8 * 60
verify_code = None
while time.time() < deadline:
s = requests.get(
f"{BASE_URL}/ilink/bot/get_qrcode_status",
params={"qrcode": qr["qrcode"]},
headers={"iLink-App-ClientVersion": "1"},
).json()
if s.get("status") == "confirmed":
params = {"qrcode": qrcode}
if verify_code:
params["verify_code"] = verify_code
s = requests.get(f"{base_url}/ilink/bot/get_qrcode_status",
params=params, headers=headers(), timeout=35).json()
st = s.get("status")
if st == "wait":
print("等待扫码...")
elif st == "scaned":
verify_code = None
print("已扫码,请在手机上确认...(此后绝不换码,避免砍掉确认会话)")
elif st == "scaned_but_redirect":
verify_code = None
if s.get("redirect_host"):
base_url = f"https://{s['redirect_host']}"
print("IDC 重定向,切换轮询至", base_url)
elif st == "need_verifycode":
verify_code = input("请在手机微信上输入显示的数字后回车: ").strip()
continue
elif st == "verify_code_blocked":
verify_code = None
print("配对码多次输入错误,重新获取二维码")
qr = fetch_qrcode(); qrcode = qr["qrcode"]; print("新二维码:", qr["qrcode_img_content"])
continue
elif st == "binded_redirect":
print("该 bot 已绑定过本实例,视为连接成功")
return
elif st == "confirmed":
state.update(bot_token=s["bot_token"], bot_id=s["ilink_bot_id"],
owner_id=s["ilink_user_id"])
owner_id=s["ilink_user_id"], baseurl=s.get("baseurl"))
save_state()
print("登录成功, bot:", s["ilink_bot_id"])
return
elif st == "expired":
# 码过期(服务端作废)→ 换新码;仅在 expired 时换,scaned 后绝不换
verify_code = None
print("二维码已过期,重新获取")
qr = fetch_qrcode(); qrcode = qr["qrcode"]; print("新二维码:", qr["qrcode_img_content"])
continue
else:
print("未知状态:", st)
time.sleep(1)
raise RuntimeError("扫码超时")