// IlinkBridge.java — 微信 ClawBot iLink 最小示例(Java 11+,零第三方依赖) // 流程: 扫码登录 → 长轮询收消息 → 回声回复 // 运行: javac IlinkBridge.java && java IlinkBridge // // 注意: 为保持零依赖,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; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.security.SecureRandom; import java.time.Duration; import java.util.*; 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 存储) ---------- static Properties state = new Properties(); static void loadState() throws Exception { if (Files.exists(STATE_FILE)) try (var in = Files.newInputStream(STATE_FILE)) { state.load(in); } } static void saveState() throws Exception { try (var out = Files.newOutputStream(STATE_FILE)) { state.store(out, "ilink bridge state"); } STATE_FILE.toFile().setReadable(false, false); STATE_FILE.toFile().setReadable(true, true); // 近似 600 } // ---------- 通用请求头: 鉴权 + 防重放 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())) .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; } static final HttpClient client = HttpClient.newHttpClient(); static String post(String path, String json) throws Exception { var r = client.send(req(BASE_URL + path) .POST(HttpRequest.BodyPublishers.ofString(json)).build(), HttpResponse.BodyHandlers.ofString()); return r.body(); } // ---------- 极简 JSON 字段提取(仅演示!正式项目用 Jackson/Gson) ---------- static String pick(String json, String key) { var m = Pattern.compile("\"" + key + "\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").matcher(json); return m.find() ? m.group(1).replace("\\\"", "\"") : null; } static List pickAll(String json, String key) { var out = new ArrayList(); var m = Pattern.compile("\"" + key + "\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").matcher(json); while (m.find()) out.add(m.group(1)); return out; } // ---------- 接口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 { // 状态机: 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) { 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(); 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); } throw new RuntimeException("扫码超时"); } // ---------- 你的 AI 后端: 替换成自己项目的调用 ---------- static String askYourAI(String peerId, String text) { // 例: 用 client 调本地 HTTP 后端 POST http://127.0.0.1:8000/chat return "你说的是:" + text; } static String esc(String s) { // 最小 JSON 转义 return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n"); } // ---------- 接口4: POST /ilink/bot/sendmessage ---------- static void sendText(String toUserId, String text, String contextToken) throws Exception { for (int i = 0; i < Math.max(text.length(), 1); i += 1500) { // 长文本分片 post("/ilink/bot/sendmessage", """ {"msg":{ "to_user_id":"%s", "client_id":"%s", "message_type":2, "message_state":2, "context_token":"%s", "item_list":[{"type":1,"text_item":{"text":"%s"}}] }}""".formatted(esc(toUserId), UUID.randomUUID(), esc(contextToken), esc(text.substring(i, Math.min(i + 1500, text.length()))))); } } // ---------- 接口3: POST /ilink/bot/getupdates 长轮询 ---------- static void pollLoop() throws Exception { int failures = 0; while (true) { try { String resp = post("/ilink/bot/getupdates", "{\"get_updates_buf\":\"" + esc(state.getProperty("cursor", "")) + "\"," + "\"base_info\":{\"channel_version\":\"1.0.2\"}}"); if ("-14".equals(pick(resp, "errcode"))) { // 会话过期 System.err.println("会话过期(errcode=-14),请删除 bridge-state.json 重新扫码"); System.exit(2); } String buf = pick(resp, "get_updates_buf"); if (buf != null && !buf.isEmpty()) { // 游标立即落盘 state.setProperty("cursor", buf); saveState(); } // 演示级提取: 逐条消息的 from_user_id / context_token / text // (正则无法严谨配对嵌套结构,正式项目务必用 JSON 库按 msgs[] 解析) var peers = pickAll(resp, "from_user_id"); var tokens = pickAll(resp, "context_token"); var texts = pickAll(resp, "text"); for (int i = 0; i < peers.size() && i < tokens.size(); i++) { String text = i < texts.size() ? texts.get(i) : ""; if (text.isBlank()) continue; state.setProperty("ctx." + peers.get(i), tokens.get(i)); saveState(); try { sendText(peers.get(i), askYourAI(peers.get(i), text), tokens.get(i)); } catch (Exception e) { System.err.println("处理消息失败: " + e); } } failures = 0; } catch (java.net.http.HttpTimeoutException e) { continue; // 长轮询正常超时 } catch (Exception e) { failures++; long wait = failures <= 3 ? 2000 : 30_000; // 退避: 2s ×3 → 30s System.err.println("轮询异常(" + failures + "次)," + wait / 1000 + "s 后重试: " + e); Thread.sleep(wait); } } } public static void main(String[] args) throws Exception { loadState(); if (state.getProperty("bot_token") == null) login(); pollLoop(); } }