docs/examples: 扫码登录改为官方 POST+local_token_list+iLink-App-Id/ClientVersion 头 + 完整状态机 + 换码竞态警告
This commit is contained in:
1 parent
38b776705d
commit
8a36915377
6 files changed
+566
-96
No files matched your search
+139
-25
@@ -37,12 +37,17 @@ const saveState = () =>
|
|||||||
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
|
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||||
|
|
||||||
// ---------- iLink HTTP 封装 ----------
|
// ---------- iLink HTTP 封装 ----------
|
||||||
|
// 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token 时不带 Authorization,
|
||||||
|
// 但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。
|
||||||
function headers() {
|
function headers() {
|
||||||
const uin = Buffer.from(String(randomBytes(4).readUInt32BE(0))).toString("base64");
|
const uin = Buffer.from(String(randomBytes(4).readUInt32BE(0))).toString("base64");
|
||||||
return {
|
return {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
AuthorizationType: "ilink_bot_token",
|
AuthorizationType: "ilink_bot_token",
|
||||||
"X-WECHAT-UIN": uin,
|
"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}` } : {}),
|
...(state.bot_token ? { Authorization: `Bearer ${state.bot_token}` } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -58,29 +63,86 @@ async function apiPost(path, body, timeoutMs = 45_000) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 1. 扫码登录(一次性) ----------
|
// ---------- 1. 扫码登录(一次性) ----------
|
||||||
async function login() {
|
// 接口1: POST /ilink/bot/get_bot_qrcode(官方姿势:body={"local_token_list":[]},非裸 GET)
|
||||||
const qr = await fetch(`${BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3`)
|
async function refreshQR() {
|
||||||
.then((r) => r.json());
|
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);
|
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;
|
const deadline = Date.now() + 8 * 60_000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const s = await fetch(
|
let ep = `${base}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
|
||||||
`${BASE_URL}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qr.qrcode)}`,
|
if (verifyCode) ep += `&verify_code=${encodeURIComponent(verifyCode)}`;
|
||||||
{ headers: { "iLink-App-ClientVersion": "1" } }
|
const s = await fetch(ep, { headers: headers() }).then((r) => r.json());
|
||||||
).then((r) => r.json());
|
|
||||||
|
|
||||||
if (s.status === "confirmed") {
|
switch (s.status) {
|
||||||
state.bot_token = s.bot_token;
|
case "wait":
|
||||||
state.bot_id = s.ilink_bot_id;
|
process.stdout.write(`\r等待扫码... status=${s.status} `);
|
||||||
state.owner_id = s.ilink_user_id;
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
saveState();
|
continue;
|
||||||
console.log("登录成功, bot:", s.ilink_bot_id);
|
case "scaned":
|
||||||
return;
|
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("扫码超时,请重试");
|
throw new Error("扫码超时,请重试");
|
||||||
}
|
}
|
||||||
@@ -203,11 +265,16 @@ def save_state():
|
|||||||
os.chmod(STATE_FILE, 0o600)
|
os.chmod(STATE_FILE, 0o600)
|
||||||
|
|
||||||
# ---------- iLink HTTP 封装 ----------
|
# ---------- iLink HTTP 封装 ----------
|
||||||
|
# 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token 时不带 Authorization,
|
||||||
|
# 但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。
|
||||||
def headers():
|
def headers():
|
||||||
h = {
|
h = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"AuthorizationType": "ilink_bot_token",
|
"AuthorizationType": "ilink_bot_token",
|
||||||
"X-WECHAT-UIN": base64.b64encode(str(random.getrandbits(32)).encode()).decode(),
|
"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"]:
|
if state["bot_token"]:
|
||||||
h["Authorization"] = f"Bearer {state['bot_token']}"
|
h["Authorization"] = f"Bearer {state['bot_token']}"
|
||||||
@@ -218,22 +285,69 @@ def api_post(path, body, timeout=45):
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
# ---------- 1. 扫码登录(一次性) ----------
|
# ---------- 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():
|
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"])
|
print("请用微信扫码:", qr["qrcode_img_content"])
|
||||||
|
# qrcode_img_content 是 liteapp 跳转 URL,须作为二维码内容编码展示,勿把 qr["qrcode"] 当二维码
|
||||||
deadline = time.time() + 8 * 60
|
deadline = time.time() + 8 * 60
|
||||||
|
verify_code = None
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
s = requests.get(
|
params = {"qrcode": qrcode}
|
||||||
f"{BASE_URL}/ilink/bot/get_qrcode_status",
|
if verify_code:
|
||||||
params={"qrcode": qr["qrcode"]},
|
params["verify_code"] = verify_code
|
||||||
headers={"iLink-App-ClientVersion": "1"},
|
s = requests.get(f"{base_url}/ilink/bot/get_qrcode_status",
|
||||||
).json()
|
params=params, headers=headers(), timeout=35).json()
|
||||||
if s.get("status") == "confirmed":
|
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"],
|
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()
|
save_state()
|
||||||
print("登录成功, bot:", s["ilink_bot_id"])
|
print("登录成功, bot:", s["ilink_bot_id"])
|
||||||
return
|
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)
|
time.sleep(1)
|
||||||
raise RuntimeError("扫码超时")
|
raise RuntimeError("扫码超时")
|
||||||
|
|
||||||
|
|||||||
+89
-17
@@ -16,6 +16,9 @@
|
|||||||
#define BASE_URL "https://ilinkai.weixin.qq.com"
|
#define BASE_URL "https://ilinkai.weixin.qq.com"
|
||||||
#define STATE_FILE "bridge-state.json"
|
#define STATE_FILE "bridge-state.json"
|
||||||
|
|
||||||
|
/* 扫码轮询 base;收到 scaned_but_redirect 时切换,get_bot_qrcode 仍走固定 BASE_URL */
|
||||||
|
static char g_base_url[256] = BASE_URL;
|
||||||
|
|
||||||
/* ---------- 响应缓冲 ---------- */
|
/* ---------- 响应缓冲 ---------- */
|
||||||
struct buf { char *data; size_t len; };
|
struct buf { char *data; size_t len; };
|
||||||
|
|
||||||
@@ -51,12 +54,14 @@ static void save_state(void) {
|
|||||||
chmod(STATE_FILE, 0600);
|
chmod(STATE_FILE, 0600);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 通用请求: 鉴权头 + 防重放 X-WECHAT-UIN ---------- */
|
/* ---------- 通用请求: 鉴权头 + 防重放 X-WECHAT-UIN + iLink-App 标识 ---------- */
|
||||||
static char *http(const char *method, const char *path, const char *json_body, long timeout) {
|
/* 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token 时不带 Authorization,
|
||||||
|
但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。 */
|
||||||
|
static char *http(const char *base, const char *method, const char *path, const char *json_body, long timeout) {
|
||||||
CURL *c = curl_easy_init();
|
CURL *c = curl_easy_init();
|
||||||
struct buf b = {0};
|
struct buf b = {0};
|
||||||
char url[512], uin[32], auth[600];
|
char url[512], uin[32], auth[600];
|
||||||
snprintf(url, sizeof url, "%s%s", BASE_URL, path);
|
snprintf(url, sizeof url, "%s%s", base, path);
|
||||||
|
|
||||||
/* X-WECHAT-UIN: base64(十进制字符串(随机uint32)),简化用伪随机 */
|
/* X-WECHAT-UIN: base64(十进制字符串(随机uint32)),简化用伪随机 */
|
||||||
snprintf(uin, sizeof uin, "%u", (unsigned)(rand() ^ (unsigned)time(NULL)));
|
snprintf(uin, sizeof uin, "%u", (unsigned)(rand() ^ (unsigned)time(NULL)));
|
||||||
@@ -77,6 +82,9 @@ static char *http(const char *method, const char *path, const char *json_body, l
|
|||||||
hdrs = curl_slist_append(hdrs, "AuthorizationType: ilink_bot_token");
|
hdrs = curl_slist_append(hdrs, "AuthorizationType: ilink_bot_token");
|
||||||
char uin_hdr[96]; snprintf(uin_hdr, sizeof uin_hdr, "X-WECHAT-UIN: %s", uin_b64);
|
char uin_hdr[96]; snprintf(uin_hdr, sizeof uin_hdr, "X-WECHAT-UIN: %s", uin_b64);
|
||||||
hdrs = curl_slist_append(hdrs, uin_hdr);
|
hdrs = curl_slist_append(hdrs, uin_hdr);
|
||||||
|
/* 2.4.8 -> (2&0xff)<<16 | (4&0xff)<<8 | (8&0xff) = 132104(数字字符串,非 "1") */
|
||||||
|
hdrs = curl_slist_append(hdrs, "iLink-App-Id: bot");
|
||||||
|
hdrs = curl_slist_append(hdrs, "iLink-App-ClientVersion: 132104");
|
||||||
if (g_token[0]) {
|
if (g_token[0]) {
|
||||||
snprintf(auth, sizeof auth, "Authorization: Bearer %s", g_token);
|
snprintf(auth, sizeof auth, "Authorization: Bearer %s", g_token);
|
||||||
hdrs = curl_slist_append(hdrs, auth);
|
hdrs = curl_slist_append(hdrs, auth);
|
||||||
@@ -108,26 +116,90 @@ static int pick(const char *json, const char *key, char *out, size_t outsz) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 接口1+2: 扫码登录(get_bot_qrcode / get_qrcode_status) ---------- */
|
/* ---------- 接口1: POST /ilink/bot/get_bot_qrcode 获取登录二维码(官方姿势) ---------- */
|
||||||
|
static char *fetch_qrcode(void) {
|
||||||
|
/* 官方为 POST + body {"local_token_list":[]},非裸 GET(裸 GET 也能拿到码但非官方姿势)。
|
||||||
|
首次绑定传空数组;若已持久化其它 bot_token 可收集传入(最多 10 个)。 */
|
||||||
|
return http(BASE_URL, "POST", "/ilink/bot/get_bot_qrcode?bot_type=3",
|
||||||
|
"{\"local_token_list\":[]}", 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 接口2: GET /ilink/bot/get_qrcode_status 轮询扫码状态(完整状态机) ---------- */
|
||||||
static void login(void) {
|
static void login(void) {
|
||||||
char *qr = http("GET", "/ilink/bot/get_bot_qrcode?bot_type=3", NULL, 15);
|
/* 状态机: 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。 */
|
||||||
|
char *qr = fetch_qrcode();
|
||||||
char qrcode[256], qrurl[512];
|
char qrcode[256], qrurl[512];
|
||||||
if (!qr || !pick(qr, "qrcode", qrcode, sizeof qrcode)) { fprintf(stderr, "获取二维码失败\n"); exit(1); }
|
if (!qr || !pick(qr, "qrcode", qrcode, sizeof qrcode)) { fprintf(stderr, "获取二维码失败\n"); exit(1); }
|
||||||
pick(qr, "qrcode_img_content", qrurl, sizeof qrurl);
|
pick(qr, "qrcode_img_content", qrurl, sizeof qrurl);
|
||||||
printf("请用微信扫码: %s\n", qrurl);
|
printf("请用微信扫码: %s\n", qrurl);
|
||||||
|
printf("(二维码内容即上面的 liteapp URL,请用二维码库编码展示;勿把 qrcode 字段当二维码)\n");
|
||||||
free(qr);
|
free(qr);
|
||||||
|
|
||||||
char path[320], status[32], token[512];
|
char verify[32] = "";
|
||||||
snprintf(path, sizeof path, "/ilink/bot/get_qrcode_status?qrcode=%s", qrcode);
|
char status[32], token[512];
|
||||||
for (int i = 0; i < 480; i++) { /* 最多等 8 分钟 */
|
for (int i = 0; i < 480; i++) { /* 最多等 8 分钟 */
|
||||||
char *s = http("GET", path, NULL, 15);
|
char path[600];
|
||||||
if (s && pick(s, "status", status, sizeof status) && !strcmp(status, "confirmed")) {
|
snprintf(path, sizeof path, "/ilink/bot/get_qrcode_status?qrcode=%s", qrcode);
|
||||||
pick(s, "bot_token", token, sizeof token);
|
if (verify[0]) {
|
||||||
snprintf(g_token, sizeof g_token, "%s", token);
|
size_t len = strlen(path);
|
||||||
save_state();
|
snprintf(path + len, sizeof path - len, "&verify_code=%s", verify);
|
||||||
printf("登录成功\n");
|
}
|
||||||
free(s);
|
char *s = http(g_base_url, "GET", path, NULL, 15);
|
||||||
return;
|
if (s && pick(s, "status", status, sizeof status)) {
|
||||||
|
if (!strcmp(status, "confirmed")) {
|
||||||
|
pick(s, "bot_token", token, sizeof token);
|
||||||
|
snprintf(g_token, sizeof g_token, "%s", token);
|
||||||
|
save_state();
|
||||||
|
printf("登录成功\n");
|
||||||
|
free(s);
|
||||||
|
return;
|
||||||
|
} else if (!strcmp(status, "scaned")) {
|
||||||
|
verify[0] = 0;
|
||||||
|
printf("已扫码,请在手机上确认...(此后绝不换码,避免砍掉确认会话)\n");
|
||||||
|
} else if (!strcmp(status, "scaned_but_redirect")) {
|
||||||
|
char host[256]; verify[0] = 0;
|
||||||
|
if (pick(s, "redirect_host", host, sizeof host) && host[0]) {
|
||||||
|
snprintf(g_base_url, sizeof g_base_url, "https://%s", host);
|
||||||
|
printf("IDC 重定向,切换轮询至 %s\n", g_base_url);
|
||||||
|
}
|
||||||
|
} else if (!strcmp(status, "need_verifycode")) {
|
||||||
|
printf("请在手机微信上输入显示的数字后回车: ");
|
||||||
|
fflush(stdout);
|
||||||
|
if (fgets(verify, sizeof verify, stdin)) {
|
||||||
|
char *nl = strchr(verify, '\n'); if (nl) *nl = 0;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
} else if (!strcmp(status, "verify_code_blocked")) {
|
||||||
|
char *nq; verify[0] = 0;
|
||||||
|
printf("配对码多次输入错误,重新获取二维码\n");
|
||||||
|
nq = fetch_qrcode();
|
||||||
|
if (!nq || !pick(nq, "qrcode", qrcode, sizeof qrcode)) { free(nq); fprintf(stderr, "获取二维码失败\n"); exit(1); }
|
||||||
|
pick(nq, "qrcode_img_content", qrurl, sizeof qrurl);
|
||||||
|
printf("新二维码: %s\n", qrurl);
|
||||||
|
free(nq);
|
||||||
|
continue;
|
||||||
|
} else if (!strcmp(status, "binded_redirect")) {
|
||||||
|
printf("该 bot 已绑定过本实例,视为连接成功\n");
|
||||||
|
free(s);
|
||||||
|
return;
|
||||||
|
} else if (!strcmp(status, "expired")) {
|
||||||
|
char *nq; verify[0] = 0;
|
||||||
|
/* 码过期(服务端作废)→ 换新码;仅在 expired 时换,scaned 后绝不换 */
|
||||||
|
printf("二维码已过期,重新获取\n");
|
||||||
|
nq = fetch_qrcode();
|
||||||
|
if (!nq || !pick(nq, "qrcode", qrcode, sizeof qrcode)) { free(nq); fprintf(stderr, "获取二维码失败\n"); exit(1); }
|
||||||
|
pick(nq, "qrcode_img_content", qrurl, sizeof qrurl);
|
||||||
|
printf("新二维码: %s\n", qrurl);
|
||||||
|
free(nq);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
fprintf(stderr, "未知状态: %s\n", status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
free(s);
|
free(s);
|
||||||
sleep(1);
|
sleep(1);
|
||||||
@@ -149,7 +221,7 @@ static void send_text(const char *to, const char *text, const char *ctx_token) {
|
|||||||
"\"message_type\":2,\"message_state\":2,\"context_token\":\"%s\","
|
"\"message_type\":2,\"message_state\":2,\"context_token\":\"%s\","
|
||||||
"\"item_list\":[{\"type\":1,\"text_item\":{\"text\":\"%s\"}}]}}",
|
"\"item_list\":[{\"type\":1,\"text_item\":{\"text\":\"%s\"}}]}}",
|
||||||
to, (long)time(NULL), ctx_token, text);
|
to, (long)time(NULL), ctx_token, text);
|
||||||
free(http("POST", "/ilink/bot/sendmessage", body, 15));
|
free(http(BASE_URL, "POST", "/ilink/bot/sendmessage", body, 15));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 接口3: POST /ilink/bot/getupdates 长轮询 ---------- */
|
/* ---------- 接口3: POST /ilink/bot/getupdates 长轮询 ---------- */
|
||||||
@@ -160,7 +232,7 @@ static void poll_loop(void) {
|
|||||||
snprintf(body, sizeof body,
|
snprintf(body, sizeof body,
|
||||||
"{\"get_updates_buf\":\"%s\",\"base_info\":{\"channel_version\":\"1.0.2\"}}",
|
"{\"get_updates_buf\":\"%s\",\"base_info\":{\"channel_version\":\"1.0.2\"}}",
|
||||||
g_cursor);
|
g_cursor);
|
||||||
char *resp = http("POST", "/ilink/bot/getupdates", body, 45);
|
char *resp = http(BASE_URL, "POST", "/ilink/bot/getupdates", body, 45);
|
||||||
if (!resp) { /* 超时/网络错误 */
|
if (!resp) { /* 超时/网络错误 */
|
||||||
failures++;
|
failures++;
|
||||||
int wait = failures <= 3 ? 2 : 30;
|
int wait = failures <= 3 ? 2 : 30;
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
static const std::string BASE_URL = "https://ilinkai.weixin.qq.com";
|
static const std::string BASE_URL = "https://ilinkai.weixin.qq.com";
|
||||||
static const std::string STATE_FILE = "bridge-state.json";
|
static const std::string STATE_FILE = "bridge-state.json";
|
||||||
|
|
||||||
|
// 扫码轮询 base;收到 scaned_but_redirect 时切换,get_bot_qrcode 仍走固定 BASE_URL
|
||||||
|
static std::string apiBaseUrl = BASE_URL;
|
||||||
|
|
||||||
// ---------- 状态: bot_token / 游标(极简 key=value 行存储) ----------
|
// ---------- 状态: bot_token / 游标(极简 key=value 行存储) ----------
|
||||||
static std::string g_token, g_cursor;
|
static std::string g_token, g_cursor;
|
||||||
|
|
||||||
@@ -63,8 +66,11 @@ static std::string b64(const std::string &in) { // 极简 base64
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 返回空 optional 表示传输层错误(超时/断网);业务错误看响应内容
|
// 返回空 optional 表示传输层错误(超时/断网);业务错误看响应内容
|
||||||
static std::optional<std::string> http(const std::string &method, const std::string &path,
|
// 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token 时不带 Authorization,
|
||||||
const std::string &body = "", long timeout = 45) {
|
// 但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。
|
||||||
|
static std::optional<std::string> http(const std::string &base, const std::string &method,
|
||||||
|
const std::string &path, const std::string &body = "",
|
||||||
|
long timeout = 45) {
|
||||||
CURL *c = curl_easy_init();
|
CURL *c = curl_easy_init();
|
||||||
std::string resp;
|
std::string resp;
|
||||||
static thread_local std::mt19937 rng{std::random_device{}()};
|
static thread_local std::mt19937 rng{std::random_device{}()};
|
||||||
@@ -74,13 +80,16 @@ static std::optional<std::string> http(const std::string &method, const std::str
|
|||||||
hdrs = curl_slist_append(hdrs, "AuthorizationType: ilink_bot_token");
|
hdrs = curl_slist_append(hdrs, "AuthorizationType: ilink_bot_token");
|
||||||
std::string uin = "X-WECHAT-UIN: " + b64(std::to_string(rng())); // 防重放
|
std::string uin = "X-WECHAT-UIN: " + b64(std::to_string(rng())); // 防重放
|
||||||
hdrs = curl_slist_append(hdrs, uin.c_str());
|
hdrs = curl_slist_append(hdrs, uin.c_str());
|
||||||
|
// 2.4.8 -> (2&0xff)<<16 | (4&0xff)<<8 | (8&0xff) = 132104(数字字符串,非 "1")
|
||||||
|
hdrs = curl_slist_append(hdrs, "iLink-App-Id: bot");
|
||||||
|
hdrs = curl_slist_append(hdrs, "iLink-App-ClientVersion: 132104");
|
||||||
std::string auth;
|
std::string auth;
|
||||||
if (!g_token.empty()) {
|
if (!g_token.empty()) {
|
||||||
auth = "Authorization: Bearer " + g_token;
|
auth = "Authorization: Bearer " + g_token;
|
||||||
hdrs = curl_slist_append(hdrs, auth.c_str());
|
hdrs = curl_slist_append(hdrs, auth.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string url = BASE_URL + path;
|
std::string url = base + path;
|
||||||
curl_easy_setopt(c, CURLOPT_URL, url.c_str());
|
curl_easy_setopt(c, CURLOPT_URL, url.c_str());
|
||||||
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
|
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
|
||||||
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, on_write);
|
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, on_write);
|
||||||
@@ -114,19 +123,77 @@ static std::string esc(const std::string &s) { // 最小 JSON 转义
|
|||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 接口1+2: 扫码登录(get_bot_qrcode / get_qrcode_status) ----------
|
// ---------- 接口1: POST /ilink/bot/get_bot_qrcode 获取登录二维码(官方姿势) ----------
|
||||||
|
static std::optional<std::string> fetch_qrcode() {
|
||||||
|
// 官方为 POST + body {"local_token_list":[]},非裸 GET。首次绑定传空数组;
|
||||||
|
// 若已持久化其它 bot_token 可收集传入(最多 10 个),官方据此识别触发 binded_redirect。
|
||||||
|
return http(BASE_URL, "POST", "/ilink/bot/get_bot_qrcode?bot_type=3",
|
||||||
|
"{\"local_token_list\":[]}", 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 接口2: GET /ilink/bot/get_qrcode_status 轮询扫码状态(完整状态机) ----------
|
||||||
static void login() {
|
static void login() {
|
||||||
auto qr = http("GET", "/ilink/bot/get_bot_qrcode?bot_type=3", "", 15);
|
// 状态机: 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。
|
||||||
|
auto qr = fetch_qrcode();
|
||||||
if (!qr) { std::cerr << "获取二维码失败\n"; exit(1); }
|
if (!qr) { std::cerr << "获取二维码失败\n"; exit(1); }
|
||||||
std::string qrcode = pick(*qr, "qrcode");
|
std::string qrcode = pick(*qr, "qrcode");
|
||||||
std::cout << "请用微信扫码: " << pick(*qr, "qrcode_img_content") << "\n";
|
std::cout << "请用微信扫码: " << pick(*qr, "qrcode_img_content") << "\n";
|
||||||
|
std::cout << "(二维码内容即上面的 liteapp URL,请用二维码库编码展示;勿把 qrcode 字段当二维码)\n";
|
||||||
|
|
||||||
|
std::string verifyCode;
|
||||||
for (int i = 0; i < 480; i++) { // 最多等 8 分钟
|
for (int i = 0; i < 480; i++) { // 最多等 8 分钟
|
||||||
auto s = http("GET", "/ilink/bot/get_qrcode_status?qrcode=" + qrcode, "", 15);
|
std::string path = "/ilink/bot/get_qrcode_status?qrcode=" + qrcode;
|
||||||
if (s && pick(*s, "status") == "confirmed") { // wait → scaned → confirmed
|
if (!verifyCode.empty()) path += "&verify_code=" + verifyCode;
|
||||||
g_token = pick(*s, "bot_token");
|
auto s = http(apiBaseUrl, "GET", path, "", 15);
|
||||||
save_state();
|
if (s) {
|
||||||
std::cout << "登录成功\n";
|
std::string status = pick(*s, "status");
|
||||||
return;
|
if (status == "confirmed") {
|
||||||
|
g_token = pick(*s, "bot_token");
|
||||||
|
save_state();
|
||||||
|
std::cout << "登录成功\n";
|
||||||
|
return;
|
||||||
|
} else if (status == "scaned") {
|
||||||
|
verifyCode.clear();
|
||||||
|
std::cout << "已扫码,请在手机上确认...(此后绝不换码,避免砍掉确认会话)\n";
|
||||||
|
} else if (status == "scaned_but_redirect") {
|
||||||
|
verifyCode.clear();
|
||||||
|
std::string host = pick(*s, "redirect_host");
|
||||||
|
if (!host.empty()) {
|
||||||
|
apiBaseUrl = "https://" + host;
|
||||||
|
std::cout << "IDC 重定向,切换轮询至 " << apiBaseUrl << "\n";
|
||||||
|
}
|
||||||
|
} else if (status == "need_verifycode") {
|
||||||
|
std::cout << "请在手机微信上输入显示的数字后回车: " << std::flush;
|
||||||
|
std::getline(std::cin, verifyCode);
|
||||||
|
continue;
|
||||||
|
} else if (status == "verify_code_blocked") {
|
||||||
|
verifyCode.clear();
|
||||||
|
std::cout << "配对码多次输入错误,重新获取二维码\n";
|
||||||
|
qr = fetch_qrcode();
|
||||||
|
if (!qr) { std::cerr << "获取二维码失败\n"; exit(1); }
|
||||||
|
qrcode = pick(*qr, "qrcode");
|
||||||
|
std::cout << "新二维码: " << pick(*qr, "qrcode_img_content") << "\n";
|
||||||
|
continue;
|
||||||
|
} else if (status == "binded_redirect") {
|
||||||
|
std::cout << "该 bot 已绑定过本实例,视为连接成功\n";
|
||||||
|
return;
|
||||||
|
} else if (status == "expired") {
|
||||||
|
// 码过期(服务端作废)→ 换新码;仅在 expired 时换,scaned 后绝不换
|
||||||
|
verifyCode.clear();
|
||||||
|
std::cout << "二维码已过期,重新获取\n";
|
||||||
|
qr = fetch_qrcode();
|
||||||
|
if (!qr) { std::cerr << "获取二维码失败\n"; exit(1); }
|
||||||
|
qrcode = pick(*qr, "qrcode");
|
||||||
|
std::cout << "新二维码: " << pick(*qr, "qrcode_img_content") << "\n";
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
std::cerr << "未知状态: " << status << "\n";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||||
}
|
}
|
||||||
@@ -147,7 +214,7 @@ static void send_text(const std::string &to, const std::string &text, const std:
|
|||||||
"\"message_type\":2,\"message_state\":2,\"context_token\":\"" + esc(token) + "\","
|
"\"message_type\":2,\"message_state\":2,\"context_token\":\"" + esc(token) + "\","
|
||||||
"\"item_list\":[{\"type\":1,\"text_item\":{\"text\":\"" +
|
"\"item_list\":[{\"type\":1,\"text_item\":{\"text\":\"" +
|
||||||
esc(text.substr(i, 1500)) + "\"}}]}}";
|
esc(text.substr(i, 1500)) + "\"}}]}}";
|
||||||
http("POST", "/ilink/bot/sendmessage", body, 15);
|
http(BASE_URL, "POST", "/ilink/bot/sendmessage", body, 15);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +222,7 @@ static void send_text(const std::string &to, const std::string &text, const std:
|
|||||||
[[noreturn]] static void poll_loop() {
|
[[noreturn]] static void poll_loop() {
|
||||||
int failures = 0;
|
int failures = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
auto resp = http("POST", "/ilink/bot/getupdates",
|
auto resp = http(BASE_URL, "POST", "/ilink/bot/getupdates",
|
||||||
"{\"get_updates_buf\":\"" + esc(g_cursor) + "\","
|
"{\"get_updates_buf\":\"" + esc(g_cursor) + "\","
|
||||||
"\"base_info\":{\"channel_version\":\"1.0.2\"}}", 45);
|
"\"base_info\":{\"channel_version\":\"1.0.2\"}}", 45);
|
||||||
if (!resp) { // 超时/网络错误
|
if (!resp) { // 超时/网络错误
|
||||||
|
|||||||
+90
-13
@@ -11,6 +11,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,12 +19,16 @@ import (
|
|||||||
|
|
||||||
const baseURL = "https://ilinkai.weixin.qq.com"
|
const baseURL = "https://ilinkai.weixin.qq.com"
|
||||||
|
|
||||||
|
// apiBaseURL 为扫码轮询的 base;收到 scaned_but_redirect 时切换,其余 API 仍走固定 baseURL。
|
||||||
|
var apiBaseURL = baseURL
|
||||||
|
|
||||||
var stateFile = filepath.Join(func() string { d, _ := os.Executable(); return filepath.Dir(d) }(), "bridge-state.json")
|
var stateFile = filepath.Join(func() string { d, _ := os.Executable(); return filepath.Dir(d) }(), "bridge-state.json")
|
||||||
|
|
||||||
// ---------- 状态持久化: bot_token / 游标 / context_token ----------
|
// ---------- 状态持久化: bot_token / 游标 / context_token ----------
|
||||||
type State struct {
|
type State struct {
|
||||||
BotToken string `json:"bot_token"`
|
BotToken string `json:"bot_token"`
|
||||||
BotID string `json:"bot_id,omitempty"`
|
BotID string `json:"bot_id,omitempty"`
|
||||||
|
BaseURL string `json:"baseurl,omitempty"`
|
||||||
Cursor string `json:"cursor"`
|
Cursor string `json:"cursor"`
|
||||||
Contexts map[string]string `json:"contexts"`
|
Contexts map[string]string `json:"contexts"`
|
||||||
}
|
}
|
||||||
@@ -44,7 +49,9 @@ func saveState() {
|
|||||||
os.WriteFile(stateFile, b, 0o600)
|
os.WriteFile(stateFile, b, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN ----------
|
// ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN + iLink-App 标识 ----------
|
||||||
|
// 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token 时不带 Authorization,
|
||||||
|
// 但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。
|
||||||
func headers(req *http.Request) {
|
func headers(req *http.Request) {
|
||||||
var u [4]byte
|
var u [4]byte
|
||||||
rand.Read(u[:])
|
rand.Read(u[:])
|
||||||
@@ -52,6 +59,9 @@ func headers(req *http.Request) {
|
|||||||
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||||
req.Header.Set("X-WECHAT-UIN", base64.StdEncoding.EncodeToString(
|
req.Header.Set("X-WECHAT-UIN", base64.StdEncoding.EncodeToString(
|
||||||
[]byte(fmt.Sprint(binary.BigEndian.Uint32(u[:])))))
|
[]byte(fmt.Sprint(binary.BigEndian.Uint32(u[:])))))
|
||||||
|
req.Header.Set("iLink-App-Id", "bot")
|
||||||
|
// 2.4.8 -> (2&0xff)<<16 | (4&0xff)<<8 | (8&0xff) = 132104(数字字符串,非 "1")
|
||||||
|
req.Header.Set("iLink-App-ClientVersion", "132104")
|
||||||
if state.BotToken != "" {
|
if state.BotToken != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+state.BotToken)
|
req.Header.Set("Authorization", "Bearer "+state.BotToken)
|
||||||
}
|
}
|
||||||
@@ -74,11 +84,13 @@ func apiPost(path string, body any, timeout time.Duration) (map[string]any, erro
|
|||||||
return out, json.NewDecoder(resp.Body).Decode(&out)
|
return out, json.NewDecoder(resp.Body).Decode(&out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apiGet 仅用于扫码状态轮询,base 用 apiBaseURL(可被 scaned_but_redirect 切换)。
|
||||||
func apiGet(path string, extraHeaders map[string]string) (map[string]any, error) {
|
func apiGet(path string, extraHeaders map[string]string) (map[string]any, error) {
|
||||||
req, err := http.NewRequest("GET", baseURL+path, nil)
|
req, err := http.NewRequest("GET", apiBaseURL+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
headers(req)
|
||||||
for k, v := range extraHeaders {
|
for k, v := range extraHeaders {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
@@ -91,34 +103,99 @@ func apiGet(path string, extraHeaders map[string]string) (map[string]any, error)
|
|||||||
return out, json.NewDecoder(resp.Body).Decode(&out)
|
return out, json.NewDecoder(resp.Body).Decode(&out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 接口1+2: 扫码登录(get_bot_qrcode / get_qrcode_status) ----------
|
// ---------- 接口1: POST /ilink/bot/get_bot_qrcode 获取登录二维码(官方姿势) ----------
|
||||||
|
func fetchQRCode() (map[string]any, error) {
|
||||||
|
// 官方为 POST + body {"local_token_list":[...]},非裸 GET。首次绑定传空数组;
|
||||||
|
// 若已持久化其它 bot_token 可收集传入(最多 10 个),官方据此识别触发 binded_redirect。
|
||||||
|
return apiPost("/ilink/bot/get_bot_qrcode?bot_type=3",
|
||||||
|
map[string]any{"local_token_list": []string{}}, 15*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 接口2: GET /ilink/bot/get_qrcode_status 轮询扫码状态(完整状态机) ----------
|
||||||
func login() error {
|
func login() error {
|
||||||
qr, err := apiGet("/ilink/bot/get_bot_qrcode?bot_type=3", nil)
|
// 状态机: 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。
|
||||||
|
qr, err := fetchQRCode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println("请用微信扫码:", qr["qrcode_img_content"])
|
qrcode, _ := qr["qrcode"].(string)
|
||||||
|
img, _ := qr["qrcode_img_content"].(string)
|
||||||
|
fmt.Println("请用微信扫码:", img)
|
||||||
|
fmt.Println("(二维码内容即上面的 liteapp URL,请用二维码库编码展示;勿把 qrcode 字段当二维码)")
|
||||||
deadline := time.Now().Add(8 * time.Minute)
|
deadline := time.Now().Add(8 * time.Minute)
|
||||||
|
var verifyCode string
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
s, err := apiGet("/ilink/bot/get_qrcode_status?qrcode="+url(qr["qrcode"]),
|
path := "/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)
|
||||||
map[string]string{"iLink-App-ClientVersion": "1"})
|
if verifyCode != "" {
|
||||||
if err == nil && s["status"] == "confirmed" { // wait → scaned → confirmed
|
path += "&verify_code=" + url.QueryEscape(verifyCode)
|
||||||
|
}
|
||||||
|
s, err := apiGet(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(time.Second) // 网络抖动按等待处理继续轮询
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch st, _ := s["status"].(string); st {
|
||||||
|
case "wait":
|
||||||
|
fmt.Println("等待扫码...")
|
||||||
|
case "scaned":
|
||||||
|
verifyCode = ""
|
||||||
|
fmt.Println("已扫码,请在手机上确认...(此后绝不换码,避免砍掉确认会话)")
|
||||||
|
case "scaned_but_redirect":
|
||||||
|
verifyCode = ""
|
||||||
|
if host, _ := s["redirect_host"].(string); host != "" {
|
||||||
|
apiBaseURL = "https://" + host
|
||||||
|
fmt.Println("IDC 重定向,切换轮询至", apiBaseURL)
|
||||||
|
}
|
||||||
|
case "need_verifycode":
|
||||||
|
fmt.Print("请在手机微信上输入显示的数字后回车: ")
|
||||||
|
fmt.Scanln(&verifyCode)
|
||||||
|
continue
|
||||||
|
case "verify_code_blocked":
|
||||||
|
verifyCode = ""
|
||||||
|
fmt.Println("配对码多次输入错误,重新获取二维码")
|
||||||
|
if qr, err = fetchQRCode(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
qrcode, _ = qr["qrcode"].(string)
|
||||||
|
img, _ = qr["qrcode_img_content"].(string)
|
||||||
|
fmt.Println("新二维码:", img)
|
||||||
|
continue
|
||||||
|
case "binded_redirect":
|
||||||
|
fmt.Println("该 bot 已绑定过本实例,视为连接成功")
|
||||||
|
return nil
|
||||||
|
case "confirmed":
|
||||||
state.BotToken, _ = s["bot_token"].(string)
|
state.BotToken, _ = s["bot_token"].(string)
|
||||||
state.BotID, _ = s["ilink_bot_id"].(string)
|
state.BotID, _ = s["ilink_bot_id"].(string)
|
||||||
|
if bu, _ := s["baseurl"].(string); bu != "" {
|
||||||
|
state.BaseURL = bu
|
||||||
|
}
|
||||||
saveState()
|
saveState()
|
||||||
fmt.Println("登录成功, bot:", state.BotID)
|
fmt.Println("登录成功, bot:", state.BotID)
|
||||||
return nil
|
return nil
|
||||||
|
case "expired":
|
||||||
|
// 码过期(服务端作废)→ 换新码;仅在 expired 时换,scaned 后绝不换
|
||||||
|
verifyCode = ""
|
||||||
|
fmt.Println("二维码已过期,重新获取")
|
||||||
|
if qr, err = fetchQRCode(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
qrcode, _ = qr["qrcode"].(string)
|
||||||
|
img, _ = qr["qrcode_img_content"].(string)
|
||||||
|
fmt.Println("新二维码:", img)
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
fmt.Println("未知状态:", st)
|
||||||
}
|
}
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("扫码超时")
|
return fmt.Errorf("扫码超时")
|
||||||
}
|
}
|
||||||
|
|
||||||
func url(v any) string { // 极简 query 转义(qrcode 内容一般安全,正式代码用 url.QueryEscape)
|
|
||||||
s, _ := v.(string)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 你的 AI 后端: 替换成自己项目的调用 ----------
|
// ---------- 你的 AI 后端: 替换成自己项目的调用 ----------
|
||||||
func AskYourAI(peerID, text string) string {
|
func AskYourAI(peerID, text string) string {
|
||||||
return "你说的是:" + text
|
return "你说的是:" + text
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// 注意: 为保持零依赖,JSON 解析用最朴素的正则提取,仅适合演示。
|
// 注意: 为保持零依赖,JSON 解析用最朴素的正则提取,仅适合演示。
|
||||||
// 正式项目请换 Jackson / Gson,并按 docs/04 的模块划分组织代码。
|
// 正式项目请换 Jackson / Gson,并按 docs/04 的模块划分组织代码。
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
@@ -17,6 +18,8 @@ import java.util.regex.*;
|
|||||||
|
|
||||||
public class IlinkBridge {
|
public class IlinkBridge {
|
||||||
static final String BASE_URL = "https://ilinkai.weixin.qq.com";
|
static final String BASE_URL = "https://ilinkai.weixin.qq.com";
|
||||||
|
// 扫码轮询的 base;收到 scaned_but_redirect 时切换,其余 API 仍走固定 BASE_URL。
|
||||||
|
static String apiBaseUrl = BASE_URL;
|
||||||
static final Path STATE_FILE = Path.of("bridge-state.json");
|
static final Path STATE_FILE = Path.of("bridge-state.json");
|
||||||
|
|
||||||
// ---------- 状态: bot_token / 游标 / context_token(极简 key=value 存储) ----------
|
// ---------- 状态: bot_token / 游标 / context_token(极简 key=value 存储) ----------
|
||||||
@@ -33,14 +36,19 @@ public class IlinkBridge {
|
|||||||
STATE_FILE.toFile().setReadable(true, true); // 近似 600
|
STATE_FILE.toFile().setReadable(true, true); // 近似 600
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN ----------
|
// ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN + iLink-App 标识 ----------
|
||||||
|
// 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token 时不带 Authorization,
|
||||||
|
// 但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。
|
||||||
static HttpRequest.Builder req(String url) {
|
static HttpRequest.Builder req(String url) {
|
||||||
var b = HttpRequest.newBuilder(URI.create(url))
|
var b = HttpRequest.newBuilder(URI.create(url))
|
||||||
.timeout(Duration.ofSeconds(50))
|
.timeout(Duration.ofSeconds(50))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("AuthorizationType", "ilink_bot_token")
|
.header("AuthorizationType", "ilink_bot_token")
|
||||||
.header("X-WECHAT-UIN", Base64.getEncoder().encodeToString(
|
.header("X-WECHAT-UIN", Base64.getEncoder().encodeToString(
|
||||||
Integer.toString(new SecureRandom().nextInt() >>> 1).getBytes()));
|
Integer.toString(new SecureRandom().nextInt() >>> 1).getBytes()))
|
||||||
|
.header("iLink-App-Id", "bot")
|
||||||
|
// 2.4.8 -> (2&0xff)<<16 | (4&0xff)<<8 | (8&0xff) = 132104(数字字符串,非 "1")
|
||||||
|
.header("iLink-App-ClientVersion", "132104");
|
||||||
String token = state.getProperty("bot_token");
|
String token = state.getProperty("bot_token");
|
||||||
if (token != null) b.header("Authorization", "Bearer " + token);
|
if (token != null) b.header("Authorization", "Bearer " + token);
|
||||||
return b;
|
return b;
|
||||||
@@ -68,23 +76,85 @@ public class IlinkBridge {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 接口1+2: 扫码登录(get_bot_qrcode / get_qrcode_status) ----------
|
// ---------- 接口1: POST /ilink/bot/get_bot_qrcode 获取登录二维码(官方姿势) ----------
|
||||||
|
static String fetchQrcode() throws Exception {
|
||||||
|
// 官方为 POST + body {"local_token_list":[...]},非裸 GET。首次绑定传空数组;
|
||||||
|
// 若已持久化其它 bot_token 可收集传入(最多 10 个),官方据此识别触发 binded_redirect。
|
||||||
|
return post("/ilink/bot/get_bot_qrcode?bot_type=3", "{\"local_token_list\":[]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 接口2: GET /ilink/bot/get_qrcode_status 轮询扫码状态(完整状态机) ----------
|
||||||
static void login() throws Exception {
|
static void login() throws Exception {
|
||||||
var qr = client.send(req(BASE_URL + "/ilink/bot/get_bot_qrcode?bot_type=3")
|
// 状态机: wait / scaned / scaned_but_redirect / need_verifycode /
|
||||||
.GET().build(), HttpResponse.BodyHandlers.ofString()).body();
|
// verify_code_blocked / binded_redirect / confirmed / expired
|
||||||
|
// 两大坑(务必读 docs/02 §4.5):
|
||||||
|
// ① 码服务端 TTL≈60s,生成后须在窗口内扫(超窗服务端自返回 expired,此处换新码)。
|
||||||
|
// ② 「换码竞态」:一旦收到 scaned(或 scaned_but_redirect / need_verifycode),
|
||||||
|
// 绝不能再换码——换码会砍掉"扫码→确认"会话,微信端显示成功但后端永远收不到 confirmed。
|
||||||
|
var qr = fetchQrcode();
|
||||||
String qrcode = pick(qr, "qrcode");
|
String qrcode = pick(qr, "qrcode");
|
||||||
|
if (qrcode == null) throw new RuntimeException("获取二维码失败");
|
||||||
System.out.println("请用微信扫码: " + pick(qr, "qrcode_img_content"));
|
System.out.println("请用微信扫码: " + pick(qr, "qrcode_img_content"));
|
||||||
|
System.out.println("(二维码内容即上面的 liteapp URL,请用二维码库编码展示;勿把 qrcode 字段当二维码)");
|
||||||
long deadline = System.currentTimeMillis() + 8 * 60_000;
|
long deadline = System.currentTimeMillis() + 8 * 60_000;
|
||||||
|
String verifyCode = null;
|
||||||
|
var sc = new Scanner(System.in);
|
||||||
while (System.currentTimeMillis() < deadline) {
|
while (System.currentTimeMillis() < deadline) {
|
||||||
var s = client.send(req(BASE_URL + "/ilink/bot/get_qrcode_status?qrcode=" + qrcode)
|
String path = "/ilink/bot/get_qrcode_status?qrcode="
|
||||||
.header("iLink-App-ClientVersion", "1").GET().build(),
|
+ URLEncoder.encode(qrcode, StandardCharsets.UTF_8);
|
||||||
|
if (verifyCode != null)
|
||||||
|
path += "&verify_code=" + URLEncoder.encode(verifyCode, StandardCharsets.UTF_8);
|
||||||
|
String s = client.send(req(apiBaseUrl + path).GET().build(),
|
||||||
HttpResponse.BodyHandlers.ofString()).body();
|
HttpResponse.BodyHandlers.ofString()).body();
|
||||||
if ("confirmed".equals(pick(s, "status"))) { // wait → scaned → confirmed
|
String status = pick(s, "status");
|
||||||
state.setProperty("bot_token", pick(s, "bot_token"));
|
switch (status == null ? "" : status) {
|
||||||
state.setProperty("bot_id", pick(s, "ilink_bot_id"));
|
case "wait":
|
||||||
saveState();
|
System.out.println("等待扫码...");
|
||||||
System.out.println("登录成功, bot: " + state.getProperty("bot_id"));
|
break;
|
||||||
return;
|
case "scaned":
|
||||||
|
verifyCode = null;
|
||||||
|
System.out.println("已扫码,请在手机上确认...(此后绝不换码,避免砍掉确认会话)");
|
||||||
|
break;
|
||||||
|
case "scaned_but_redirect":
|
||||||
|
verifyCode = null;
|
||||||
|
String host = pick(s, "redirect_host");
|
||||||
|
if (host != null && !host.isEmpty()) {
|
||||||
|
apiBaseUrl = "https://" + host;
|
||||||
|
System.out.println("IDC 重定向,切换轮询至 " + apiBaseUrl);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "need_verifycode":
|
||||||
|
System.out.print("请在手机微信上输入显示的数字后回车: ");
|
||||||
|
System.out.flush();
|
||||||
|
verifyCode = sc.nextLine().trim();
|
||||||
|
continue;
|
||||||
|
case "verify_code_blocked":
|
||||||
|
verifyCode = null;
|
||||||
|
System.out.println("配对码多次输入错误,重新获取二维码");
|
||||||
|
qr = fetchQrcode();
|
||||||
|
qrcode = pick(qr, "qrcode");
|
||||||
|
System.out.println("新二维码: " + pick(qr, "qrcode_img_content"));
|
||||||
|
continue;
|
||||||
|
case "binded_redirect":
|
||||||
|
System.out.println("该 bot 已绑定过本实例,视为连接成功");
|
||||||
|
return;
|
||||||
|
case "confirmed":
|
||||||
|
state.setProperty("bot_token", pick(s, "bot_token"));
|
||||||
|
state.setProperty("bot_id", pick(s, "ilink_bot_id"));
|
||||||
|
if (pick(s, "baseurl") != null) state.setProperty("baseurl", pick(s, "baseurl"));
|
||||||
|
saveState();
|
||||||
|
System.out.println("登录成功, bot: " + state.getProperty("bot_id"));
|
||||||
|
return;
|
||||||
|
case "expired":
|
||||||
|
// 码过期(服务端作废)→ 换新码;仅在 expired 时换,scaned 后绝不换
|
||||||
|
verifyCode = null;
|
||||||
|
System.out.println("二维码已过期,重新获取");
|
||||||
|
qr = fetchQrcode();
|
||||||
|
qrcode = pick(qr, "qrcode");
|
||||||
|
System.out.println("新二维码: " + pick(qr, "qrcode_img_content"));
|
||||||
|
continue;
|
||||||
|
default:
|
||||||
|
System.out.println("未知状态: " + status);
|
||||||
}
|
}
|
||||||
Thread.sleep(1000);
|
Thread.sleep(1000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# ilink_bridge.py — 微信 ClawBot iLink 最小示例(Python)
|
# ilink_bridge.py — 微信 ClawBot iLink 最小示例(Python)
|
||||||
# 流程: 扫码登录 → 长轮询收消息 → 回声回复
|
# 流程: 扫码登录(官方 POST get_bot_qrcode + local_token_list + 完整状态机)→ 长轮询收消息 → 回声回复
|
||||||
# 依赖: pip install requests
|
# 依赖: pip install requests(二维码展示可加装 qrcode,非必需)
|
||||||
import json, os, time, uuid, base64, random, requests
|
import json, os, time, uuid, base64, random, requests
|
||||||
|
|
||||||
BASE_URL = "https://ilinkai.weixin.qq.com"
|
BASE_URL = "https://ilinkai.weixin.qq.com"
|
||||||
@@ -17,12 +17,17 @@ def save_state():
|
|||||||
json.dump(state, f, indent=2)
|
json.dump(state, f, indent=2)
|
||||||
os.chmod(STATE_FILE, 0o600)
|
os.chmod(STATE_FILE, 0o600)
|
||||||
|
|
||||||
# ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN ----------
|
# ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN + iLink-App 标识 ----------
|
||||||
def headers():
|
def headers():
|
||||||
|
# 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token,不加 Authorization;
|
||||||
|
# 但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。
|
||||||
h = {
|
h = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"AuthorizationType": "ilink_bot_token",
|
"AuthorizationType": "ilink_bot_token",
|
||||||
"X-WECHAT-UIN": base64.b64encode(str(random.getrandbits(32)).encode()).decode(),
|
"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"]:
|
if state["bot_token"]:
|
||||||
h["Authorization"] = f"Bearer {state['bot_token']}"
|
h["Authorization"] = f"Bearer {state['bot_token']}"
|
||||||
@@ -31,22 +36,87 @@ def headers():
|
|||||||
def api_post(path, body, timeout=45):
|
def api_post(path, body, timeout=45):
|
||||||
return requests.post(BASE_URL + path, headers=headers(), json=body, timeout=timeout).json()
|
return requests.post(BASE_URL + path, headers=headers(), json=body, timeout=timeout).json()
|
||||||
|
|
||||||
# ---------- 接口1: GET /ilink/bot/get_bot_qrcode 获取登录二维码 ----------
|
# ---------- 接口1: POST /ilink/bot/get_bot_qrcode 获取登录二维码(官方姿势) ----------
|
||||||
# ---------- 接口2: GET /ilink/bot/get_qrcode_status 轮询扫码状态 ----------
|
def fetch_qrcode():
|
||||||
|
# 官方为 POST + body {"local_token_list":[...]},非裸 GET(裸 GET 也能拿到码但非官方姿势)。
|
||||||
|
# local_token_list: 本实例已登录的 bot token 列表;首次绑定传空数组 []。若已持久化其它 bot,
|
||||||
|
# 可收集 token 传入(最多 10 个),官方据此识别(较可靠地触发 binded_redirect)。
|
||||||
|
return requests.post(f"{BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3",
|
||||||
|
headers=headers(), json={"local_token_list": []}, timeout=15).json()
|
||||||
|
|
||||||
|
def show_qrcode(url):
|
||||||
|
# qrcode_img_content 是 liteapp 跳转 URL(liteapp.weixin.qq.com/q/...),须作为"二维码内容"
|
||||||
|
# 用二维码库编码展示;勿把响应里的 qrcode 字段当二维码(会扫出一串文本)。
|
||||||
|
try:
|
||||||
|
import qrcode
|
||||||
|
qr = qrcode.QRCode(border=1)
|
||||||
|
qr.add_data(url)
|
||||||
|
qr.make()
|
||||||
|
qr.print_ascii()
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 无 qrcode 库时降级:打印 URL(微信扫此 URL 同样可完成确认,只是非标准"二维码"展示)。
|
||||||
|
print("二维码内容(URL):", url)
|
||||||
|
|
||||||
|
# ---------- 接口2: GET /ilink/bot/get_qrcode_status 轮询扫码状态(完整状态机) ----------
|
||||||
def login():
|
def login():
|
||||||
qr = requests.get(f"{BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3").json()
|
# 状态机: wait / scaned / scaned_but_redirect / need_verifycode /
|
||||||
print("请用微信扫码:", qr["qrcode_img_content"])
|
# 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 # scaned_but_redirect 时切换轮询 host
|
||||||
|
qr = fetch_qrcode()
|
||||||
|
qrcode = qr["qrcode"]
|
||||||
|
show_qrcode(qr["qrcode_img_content"])
|
||||||
deadline = time.time() + 8 * 60
|
deadline = time.time() + 8 * 60
|
||||||
|
verify_code = None
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
s = requests.get(f"{BASE_URL}/ilink/bot/get_qrcode_status",
|
params = {"qrcode": qrcode}
|
||||||
params={"qrcode": qr["qrcode"]},
|
if verify_code:
|
||||||
headers={"iLink-App-ClientVersion": "1"}).json()
|
params["verify_code"] = verify_code
|
||||||
if s.get("status") == "confirmed": # 状态流转: wait → scaned → confirmed
|
s = requests.get(f"{base_url}/ilink/bot/get_qrcode_status",
|
||||||
state.update(bot_token=s["bot_token"], bot_id=s["ilink_bot_id"],
|
params=params, headers=headers(), timeout=35).json()
|
||||||
owner_id=s["ilink_user_id"])
|
st = s.get("status")
|
||||||
save_state()
|
if st == "wait":
|
||||||
print("登录成功, bot:", s["ilink_bot_id"])
|
print("等待扫码...")
|
||||||
|
elif st == "scaned":
|
||||||
|
verify_code = None
|
||||||
|
print("已扫码,请在手机上确认...(此后绝不换码,避免砍掉确认会话)")
|
||||||
|
elif st == "scaned_but_redirect":
|
||||||
|
verify_code = None
|
||||||
|
host = s.get("redirect_host")
|
||||||
|
if host:
|
||||||
|
base_url = f"https://{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"]; show_qrcode(qr["qrcode_img_content"])
|
||||||
|
continue
|
||||||
|
elif st == "binded_redirect":
|
||||||
|
print("该 bot 已绑定过本实例,视为连接成功")
|
||||||
return
|
return
|
||||||
|
elif st == "confirmed":
|
||||||
|
state.update(bot_token=s["bot_token"], bot_id=s.get("ilink_bot_id"),
|
||||||
|
owner_id=s.get("ilink_user_id"), baseurl=s.get("baseurl"))
|
||||||
|
save_state()
|
||||||
|
print("登录成功, bot:", s.get("ilink_bot_id"))
|
||||||
|
return
|
||||||
|
elif st == "expired":
|
||||||
|
# 码过期(服务端作废)→ 换新码;仅在 expired 时换,scaned 后绝不换
|
||||||
|
verify_code = None
|
||||||
|
print("二维码已过期,重新获取")
|
||||||
|
qr = fetch_qrcode(); qrcode = qr["qrcode"]; show_qrcode(qr["qrcode_img_content"])
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
print("未知状态:", st)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
raise RuntimeError("扫码超时")
|
raise RuntimeError("扫码超时")
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user