docs/examples: 扫码登录改为官方 POST+local_token_list+iLink-App-Id/ClientVersion 头 + 完整状态机 + 换码竞态警告

This commit is contained in:
AskaEth committed 2026-09-08 02:46:07 +08:00
1 parent 38b776705d
commit 8a36915377
6 files changed
+566 -96

No files matched your search

+90 -13
View File
@@ -11,6 +11,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"time"
@@ -18,12 +19,16 @@ import (
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"`
}
@@ -44,7 +49,9 @@ func saveState() {
os.WriteFile(stateFile, b, 0o600)
}
// ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN ----------
// ---------- 通用请求头: 鉴权 + 防重放 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[:])
@@ -52,6 +59,9 @@ func headers(req *http.Request) {
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)
}
@@ -74,11 +84,13 @@ func apiPost(path string, body any, timeout time.Duration) (map[string]any, erro
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", baseURL+path, nil)
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)
}
@@ -91,34 +103,99 @@ func apiGet(path string, extraHeaders map[string]string) (map[string]any, error)
return out, json.NewDecoder(resp.Body).Decode(&out)
}
// ---------- 接口1+2: 扫码登录(get_bot_qrcode / get_qrcode_status) ----------
// ---------- 接口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 {
qr, err := apiGet("/ilink/bot/get_bot_qrcode?bot_type=3", nil)
// 状态机: 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
}
fmt.Println("请用微信扫码:", qr["qrcode_img_content"])
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) {
s, err := apiGet("/ilink/bot/get_qrcode_status?qrcode="+url(qr["qrcode"]),
map[string]string{"iLink-App-ClientVersion": "1"})
if err == nil && s["status"] == "confirmed" { // wait → scaned → confirmed
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("扫码超时")
}
func url(v any) string { // 极简 query 转义(qrcode 内容一般安全,正式代码用 url.QueryEscape)
s, _ := v.(string)
return s
}
// ---------- 你的 AI 后端: 替换成自己项目的调用 ----------
func AskYourAI(peerID, text string) string {
return "你说的是:" + text