Files

244 lines
12 KiB
Java
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<String> pickAll(String json, String key) {
var out = new ArrayList<String>();
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();
}
}