// main.go — 微信 ClawBot iLink 最小示例(Go,仅标准库) // 流程: 扫码登录 → 长轮询收消息 → 回声回复 // 运行: go run main.go package main import ( "bytes" "crypto/rand" "encoding/base64" "encoding/binary" "encoding/json" "fmt" "net/http" "net/url" "os" "path/filepath" "time" ) 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"` } var state = State{Contexts: map[string]string{}} func loadState() { if b, err := os.ReadFile(stateFile); err == nil { json.Unmarshal(b, &state) } if state.Contexts == nil { state.Contexts = map[string]string{} } } func saveState() { b, _ := json.MarshalIndent(state, "", " ") os.WriteFile(stateFile, b, 0o600) } // ---------- 通用请求头: 鉴权 + 防重放 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[:]) req.Header.Set("Content-Type", "application/json") 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) } } func apiPost(path string, body any, timeout time.Duration) (map[string]any, error) { buf, _ := json.Marshal(body) req, err := http.NewRequest("POST", baseURL+path, bytes.NewReader(buf)) if err != nil { return nil, err } headers(req) client := &http.Client{Timeout: timeout} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var out map[string]any 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", apiBaseURL+path, nil) if err != nil { return nil, err } headers(req) for k, v := range extraHeaders { req.Header.Set(k, v) } resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) if err != nil { return nil, err } defer resp.Body.Close() var out map[string]any return out, json.NewDecoder(resp.Body).Decode(&out) } // ---------- 接口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 { // 状态机: 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 } 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) { 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("扫码超时") } // ---------- 你的 AI 后端: 替换成自己项目的调用 ---------- func AskYourAI(peerID, text string) string { return "你说的是:" + text } // ---------- 接口4: POST /ilink/bot/sendmessage ---------- func sendText(toUserID, text, contextToken string) { if text == "" { text = " " } for i := 0; i < len(text); i += 1500 { // 长文本分片(按字节切仅适合演示,正式按 rune 切) end := i + 1500 if end > len(text) { end = len(text) } apiPost("/ilink/bot/sendmessage", map[string]any{ "msg": map[string]any{ "to_user_id": toUserID, "client_id": fmt.Sprintf("%d-%d", time.Now().UnixNano(), i), "message_type": 2, // bot 消息 "message_state": 2, // FINISH "context_token": contextToken, "item_list": []any{map[string]any{ "type": 1, "text_item": map[string]string{"text": text[i:end]}, }}, }, }, 15*time.Second) } } // ---------- 接口3: POST /ilink/bot/getupdates 长轮询 ---------- func pollLoop() { failures := 0 for { resp, err := apiPost("/ilink/bot/getupdates", map[string]any{ "get_updates_buf": state.Cursor, "base_info": map[string]string{"channel_version": "1.0.2"}, }, 45*time.Second) if err != nil { if os.IsTimeout(err) { // 长轮询正常超时 continue } failures++ wait := 2 * time.Second if failures > 3 { wait = 30 * time.Second // 退避: 2s ×3 → 30s } fmt.Printf("轮询异常(%d次),%v 后重试: %v\n", failures, wait, err) time.Sleep(wait) continue } if code, _ := resp["errcode"].(float64); code == -14 { // 会话过期 fmt.Println("会话过期(errcode=-14),请删除 bridge-state.json 重新扫码") os.Exit(2) } if buf, ok := resp["get_updates_buf"].(string); ok && buf != "" { state.Cursor = buf // 游标立即落盘 saveState() } msgs, _ := resp["msgs"].([]any) for _, m := range msgs { msg, _ := m.(map[string]any) if t, _ := msg["message_type"].(float64); t != 1 { // 只处理用户消息 continue } text := "" for _, it := range msg["item_list"].([]any) { item, _ := it.(map[string]any) if t, _ := item["type"].(float64); t == 1 { if ti, ok := item["text_item"].(map[string]any); ok { s, _ := ti["text"].(string) text += s + "\n" } } } if text == "" { continue } peer, _ := msg["from_user_id"].(string) token, _ := msg["context_token"].(string) state.Contexts[peer] = token saveState() sendText(peer, AskYourAI(peer, text), token) } failures = 0 } } func main() { loadState() if state.BotToken == "" { if err := login(); err != nil { fmt.Println(err) os.Exit(1) } } pollLoop() }