Files
weixin_ClawBot_Bridge/examples/go/main.go
T

301 lines
9.3 KiB
Go
Raw 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.
// 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()
}