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