diff --git a/docs/05-implementation-guide.md b/docs/05-implementation-guide.md index 0c3f413..3b5e033 100644 --- a/docs/05-implementation-guide.md +++ b/docs/05-implementation-guide.md @@ -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("扫码超时") diff --git a/examples/c/ilink_client.c b/examples/c/ilink_client.c index 0ca8194..e001ed4 100644 --- a/examples/c/ilink_client.c +++ b/examples/c/ilink_client.c @@ -16,6 +16,9 @@ #define BASE_URL "https://ilinkai.weixin.qq.com" #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; }; @@ -51,12 +54,14 @@ static void save_state(void) { chmod(STATE_FILE, 0600); } -/* ---------- 通用请求: 鉴权头 + 防重放 X-WECHAT-UIN ---------- */ -static char *http(const char *method, const char *path, const char *json_body, long timeout) { +/* ---------- 通用请求: 鉴权头 + 防重放 X-WECHAT-UIN + iLink-App 标识 ---------- */ +/* 注: 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(); struct buf b = {0}; 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)),简化用伪随机 */ 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"); char uin_hdr[96]; snprintf(uin_hdr, sizeof uin_hdr, "X-WECHAT-UIN: %s", uin_b64); 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]) { snprintf(auth, sizeof auth, "Authorization: Bearer %s", g_token); 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; } -/* ---------- 接口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) { - 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]; if (!qr || !pick(qr, "qrcode", qrcode, sizeof qrcode)) { fprintf(stderr, "获取二维码失败\n"); exit(1); } pick(qr, "qrcode_img_content", qrurl, sizeof qrurl); printf("请用微信扫码: %s\n", qrurl); + printf("(二维码内容即上面的 liteapp URL,请用二维码库编码展示;勿把 qrcode 字段当二维码)\n"); free(qr); - char path[320], status[32], token[512]; - snprintf(path, sizeof path, "/ilink/bot/get_qrcode_status?qrcode=%s", qrcode); + char verify[32] = ""; + char status[32], token[512]; for (int i = 0; i < 480; i++) { /* 最多等 8 分钟 */ - char *s = http("GET", path, NULL, 15); - if (s && pick(s, "status", status, sizeof status) && !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; + char path[600]; + snprintf(path, sizeof path, "/ilink/bot/get_qrcode_status?qrcode=%s", qrcode); + if (verify[0]) { + size_t len = strlen(path); + snprintf(path + len, sizeof path - len, "&verify_code=%s", verify); + } + char *s = http(g_base_url, "GET", path, NULL, 15); + 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); 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\"," "\"item_list\":[{\"type\":1,\"text_item\":{\"text\":\"%s\"}}]}}", 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 长轮询 ---------- */ @@ -160,7 +232,7 @@ static void poll_loop(void) { snprintf(body, sizeof body, "{\"get_updates_buf\":\"%s\",\"base_info\":{\"channel_version\":\"1.0.2\"}}", g_cursor); - char *resp = http("POST", "/ilink/bot/getupdates", body, 45); + char *resp = http(BASE_URL, "POST", "/ilink/bot/getupdates", body, 45); if (!resp) { /* 超时/网络错误 */ failures++; int wait = failures <= 3 ? 2 : 30; diff --git a/examples/cpp/ilink_client.cpp b/examples/cpp/ilink_client.cpp index 182603b..fbf54be 100644 --- a/examples/cpp/ilink_client.cpp +++ b/examples/cpp/ilink_client.cpp @@ -19,6 +19,9 @@ static const std::string BASE_URL = "https://ilinkai.weixin.qq.com"; 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 行存储) ---------- static std::string g_token, g_cursor; @@ -63,8 +66,11 @@ static std::string b64(const std::string &in) { // 极简 base64 } // 返回空 optional 表示传输层错误(超时/断网);业务错误看响应内容 -static std::optional http(const std::string &method, const std::string &path, - const std::string &body = "", long timeout = 45) { +// 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token 时不带 Authorization, +// 但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。 +static std::optional 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(); std::string resp; static thread_local std::mt19937 rng{std::random_device{}()}; @@ -74,13 +80,16 @@ static std::optional http(const std::string &method, const std::str hdrs = curl_slist_append(hdrs, "AuthorizationType: ilink_bot_token"); std::string uin = "X-WECHAT-UIN: " + b64(std::to_string(rng())); // 防重放 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; if (!g_token.empty()) { auth = "Authorization: Bearer " + g_token; 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_HTTPHEADER, hdrs); curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, on_write); @@ -114,19 +123,77 @@ static std::string esc(const std::string &s) { // 最小 JSON 转义 return o; } -// ---------- 接口1+2: 扫码登录(get_bot_qrcode / get_qrcode_status) ---------- +// ---------- 接口1: POST /ilink/bot/get_bot_qrcode 获取登录二维码(官方姿势) ---------- +static std::optional 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() { - 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); } std::string qrcode = pick(*qr, "qrcode"); 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 分钟 - auto s = http("GET", "/ilink/bot/get_qrcode_status?qrcode=" + qrcode, "", 15); - if (s && pick(*s, "status") == "confirmed") { // wait → scaned → confirmed - g_token = pick(*s, "bot_token"); - save_state(); - std::cout << "登录成功\n"; - return; + std::string path = "/ilink/bot/get_qrcode_status?qrcode=" + qrcode; + if (!verifyCode.empty()) path += "&verify_code=" + verifyCode; + auto s = http(apiBaseUrl, "GET", path, "", 15); + if (s) { + std::string status = pick(*s, "status"); + 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)); } @@ -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) + "\"," "\"item_list\":[{\"type\":1,\"text_item\":{\"text\":\"" + 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() { int failures = 0; for (;;) { - auto resp = http("POST", "/ilink/bot/getupdates", + auto resp = http(BASE_URL, "POST", "/ilink/bot/getupdates", "{\"get_updates_buf\":\"" + esc(g_cursor) + "\"," "\"base_info\":{\"channel_version\":\"1.0.2\"}}", 45); if (!resp) { // 超时/网络错误 diff --git a/examples/go/main.go b/examples/go/main.go index 507fa6c..1a64cce 100644 --- a/examples/go/main.go +++ b/examples/go/main.go @@ -11,6 +11,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "os" "path/filepath" "time" @@ -18,12 +19,16 @@ import ( 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") // ---------- 状态持久化: bot_token / 游标 / context_token ---------- type State struct { BotToken string `json:"bot_token"` BotID string `json:"bot_id,omitempty"` + BaseURL string `json:"baseurl,omitempty"` Cursor string `json:"cursor"` Contexts map[string]string `json:"contexts"` } @@ -44,7 +49,9 @@ func saveState() { 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) { var u [4]byte rand.Read(u[:]) @@ -52,6 +59,9 @@ func headers(req *http.Request) { req.Header.Set("AuthorizationType", "ilink_bot_token") req.Header.Set("X-WECHAT-UIN", base64.StdEncoding.EncodeToString( []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 != "" { 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) } +// apiGet 仅用于扫码状态轮询,base 用 apiBaseURL(可被 scaned_but_redirect 切换)。 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 { return nil, err } + headers(req) for k, v := range extraHeaders { 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) } -// ---------- 接口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 { - 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 { 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) + var verifyCode string for time.Now().Before(deadline) { - s, err := apiGet("/ilink/bot/get_qrcode_status?qrcode="+url(qr["qrcode"]), - map[string]string{"iLink-App-ClientVersion": "1"}) - if err == nil && s["status"] == "confirmed" { // wait → scaned → confirmed + path := "/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode) + if verifyCode != "" { + 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.BotID, _ = s["ilink_bot_id"].(string) + if bu, _ := s["baseurl"].(string); bu != "" { + state.BaseURL = bu + } saveState() fmt.Println("登录成功, bot:", state.BotID) 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) } return fmt.Errorf("扫码超时") } -func url(v any) string { // 极简 query 转义(qrcode 内容一般安全,正式代码用 url.QueryEscape) - s, _ := v.(string) - return s -} - // ---------- 你的 AI 后端: 替换成自己项目的调用 ---------- func AskYourAI(peerID, text string) string { return "你说的是:" + text diff --git a/examples/java/IlinkBridge.java b/examples/java/IlinkBridge.java index fdf1c19..dc2df29 100644 --- a/examples/java/IlinkBridge.java +++ b/examples/java/IlinkBridge.java @@ -5,6 +5,7 @@ // 注意: 为保持零依赖,JSON 解析用最朴素的正则提取,仅适合演示。 // 正式项目请换 Jackson / Gson,并按 docs/04 的模块划分组织代码。 import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -17,6 +18,8 @@ import java.util.regex.*; public class IlinkBridge { 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"); // ---------- 状态: bot_token / 游标 / context_token(极简 key=value 存储) ---------- @@ -33,14 +36,19 @@ public class IlinkBridge { 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) { var b = HttpRequest.newBuilder(URI.create(url)) .timeout(Duration.ofSeconds(50)) .header("Content-Type", "application/json") .header("AuthorizationType", "ilink_bot_token") .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"); if (token != null) b.header("Authorization", "Bearer " + token); return b; @@ -68,23 +76,85 @@ public class IlinkBridge { 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 { - var qr = client.send(req(BASE_URL + "/ilink/bot/get_bot_qrcode?bot_type=3") - .GET().build(), HttpResponse.BodyHandlers.ofString()).body(); + // 状态机: 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。 + var qr = fetchQrcode(); String qrcode = pick(qr, "qrcode"); + if (qrcode == null) throw new RuntimeException("获取二维码失败"); System.out.println("请用微信扫码: " + pick(qr, "qrcode_img_content")); + System.out.println("(二维码内容即上面的 liteapp URL,请用二维码库编码展示;勿把 qrcode 字段当二维码)"); long deadline = System.currentTimeMillis() + 8 * 60_000; + String verifyCode = null; + var sc = new Scanner(System.in); while (System.currentTimeMillis() < deadline) { - var s = client.send(req(BASE_URL + "/ilink/bot/get_qrcode_status?qrcode=" + qrcode) - .header("iLink-App-ClientVersion", "1").GET().build(), + String path = "/ilink/bot/get_qrcode_status?qrcode=" + + 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(); - if ("confirmed".equals(pick(s, "status"))) { // wait → scaned → confirmed - state.setProperty("bot_token", pick(s, "bot_token")); - state.setProperty("bot_id", pick(s, "ilink_bot_id")); - saveState(); - System.out.println("登录成功, bot: " + state.getProperty("bot_id")); - return; + String status = pick(s, "status"); + switch (status == null ? "" : status) { + case "wait": + System.out.println("等待扫码..."); + break; + 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); } diff --git a/examples/python/ilink_bridge.py b/examples/python/ilink_bridge.py index b7d810f..0af58a3 100644 --- a/examples/python/ilink_bridge.py +++ b/examples/python/ilink_bridge.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # ilink_bridge.py — 微信 ClawBot iLink 最小示例(Python) -# 流程: 扫码登录 → 长轮询收消息 → 回声回复 -# 依赖: pip install requests +# 流程: 扫码登录(官方 POST get_bot_qrcode + local_token_list + 完整状态机)→ 长轮询收消息 → 回声回复 +# 依赖: pip install requests(二维码展示可加装 qrcode,非必需) import json, os, time, uuid, base64, random, requests BASE_URL = "https://ilinkai.weixin.qq.com" @@ -17,12 +17,17 @@ def save_state(): json.dump(state, f, indent=2) os.chmod(STATE_FILE, 0o600) -# ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN ---------- +# ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN + iLink-App 标识 ---------- def headers(): + # 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token,不加 Authorization; + # 但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。 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']}" @@ -31,22 +36,87 @@ def headers(): def api_post(path, body, timeout=45): return requests.post(BASE_URL + path, headers=headers(), json=body, timeout=timeout).json() -# ---------- 接口1: GET /ilink/bot/get_bot_qrcode 获取登录二维码 ---------- -# ---------- 接口2: GET /ilink/bot/get_qrcode_status 轮询扫码状态 ---------- +# ---------- 接口1: POST /ilink/bot/get_bot_qrcode 获取登录二维码(官方姿势) ---------- +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(): - qr = requests.get(f"{BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3").json() - print("请用微信扫码:", qr["qrcode_img_content"]) + # 状态机: 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 # scaned_but_redirect 时切换轮询 host + qr = fetch_qrcode() + qrcode = qr["qrcode"] + show_qrcode(qr["qrcode_img_content"]) 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": # 状态流转: wait → scaned → confirmed - state.update(bot_token=s["bot_token"], bot_id=s["ilink_bot_id"], - owner_id=s["ilink_user_id"]) - save_state() - print("登录成功, bot:", s["ilink_bot_id"]) + 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 + 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 + 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) raise RuntimeError("扫码超时")