feat(weixin_ilink): 扫码协议与腾讯官方对齐——POST local_token_list + iLink-App-Id/ClientVersion 头 + need_verifycode/redirect 状态机
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
Aska committed 2026-09-07 22:49:25 +08:00
1 parent bef398d47a
commit f5950e4bde
9 files changed
+508 -99

No files matched your search

+17 -78
View File
@@ -16,16 +16,10 @@ package weixin_ilink
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -63,6 +57,12 @@ const (
botType = 3
// channelVersion is the base_info.channel_version sent on getupdates.
channelVersion = "1.0.2"
// ilinkAppID mirrors the official package.json ilink_appid field and is sent
// as the iLink-App-Id header on every request.
ilinkAppID = "bot"
// ilinkVersion mirrors the upstream @tencent-weixin/openclaw-weixin version
// whose client version we advertise on the wire as iLink-App-ClientVersion.
ilinkVersion = "2.4.8"
// errSessionExpired is the iLink errcode for an expired bot token.
errSessionExpired = -14
)
@@ -160,9 +160,13 @@ type Adapter struct {
mu sync.RWMutex
connected bool
qrcode string
contexts map[string]string
lastMsgAt map[string]time.Time
handler adapter.MessageHandler
// pollBaseURL optionally overrides cfg.BaseURL for get_qrcode_status polling.
// It is set when the server returns scaned_but_redirect with a redirect_host,
// so subsequent status polls follow the IDC host. Guarded by mu.
pollBaseURL string
contexts map[string]string
lastMsgAt map[string]time.Time
handler adapter.MessageHandler
// loginMu serializes the scan-login session lifecycle (StartLogin/Connect),
// so only one QR bind runs at a time. loginSession / loginStatus / loginDetail
@@ -373,72 +377,7 @@ func recipient(m adapter.UnifiedMessage) (peer string, group bool) {
}
// --- HTTP plumbing ---
// authorizationHeader decorates req with the iLink common headers: JSON
// content type, the bearer token, the auth type, and a fresh random
// X-WECHAT-UIN replay guard.
func (a *Adapter) authorizationHeader(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
req.Header.Set("X-WECHAT-UIN", randomUIN())
a.mu.RLock()
token := a.cfg.BotToken
a.mu.RUnlock()
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
// randomUIN returns a base64-encoded decimal uint32, minted fresh per request
// as iLink's anti-replay value.
func randomUIN() string {
var b [4]byte
_, _ = rand.Read(b[:])
n := binary.BigEndian.Uint32(b[:])
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprint(n)))
}
// doJSON performs an HTTP request and decodes the JSON response into out. It
// closes the response body and wraps network/decode errors. A nil out skips
// decoding (used for PUTs whose body is opaque bytes).
func (a *Adapter) doJSON(ctx context.Context, method, rawURL string, body io.Reader, out any) error {
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return fmt.Errorf("weixin_ilink: build %s %s: %w", method, rawURL, err)
}
a.authorizationHeader(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("weixin_ilink: %s %s: %w", method, rawURL, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("weixin_ilink: %s %s: status %d: %s",
method, rawURL, resp.StatusCode, strings.TrimSpace(string(msg)))
}
if out == nil {
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
return fmt.Errorf("weixin_ilink: drain %s %s: %w", method, rawURL, err)
}
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("weixin_ilink: decode %s %s: %w", method, rawURL, err)
}
return nil
}
// apiURL joins a path with the configured base URL.
func (a *Adapter) apiURL(path string) string {
return strings.TrimRight(a.cfg.BaseURL, "/") + path
}
// cdnURL joins a path with the configured CDN base URL, escaping raw query
// strings that some CDN references carry.
func (a *Adapter) cdnURL(raw string) string {
if u, err := url.Parse(raw); err == nil && u.IsAbs() {
return raw
}
return strings.TrimRight(a.cfg.CDNBaseURL, "/") + "/" + strings.TrimLeft(raw, "/")
}
//
// The request/response HTTP helpers (authorizationHeader, doJSON, apiURL,
// joinURL, pollBase, setPollBaseURL, buildClientVersion, cdnURL) live in
// adapter_http.go so this file stays under the project's ≤500-line limit.
@@ -0,0 +1,140 @@
package weixin_ilink
// This file holds the HTTP plumbing for the iLink adapter (request building and
// response decoding). It is split from adapter.go so that file stays under the
// project's ≤500-line source-file limit. Nothing here exports a symbol the
// rest of the package or its tests rely on being in a specific file.
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
// authorizationHeader decorates req with the iLink common headers: JSON
// content type, the bearer token, the auth type, a fresh random
// X-WECHAT-UIN replay guard, and the App identify headers.
func (a *Adapter) authorizationHeader(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
req.Header.Set("X-WECHAT-UIN", randomUIN())
req.Header.Set("iLink-App-Id", ilinkAppID)
req.Header.Set("iLink-App-ClientVersion", buildClientVersion(ilinkVersion))
a.mu.RLock()
token := a.cfg.BotToken
a.mu.RUnlock()
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
// randomUIN returns a base64-encoded decimal uint32, minted fresh per request
// as iLink's anti-replay value.
func randomUIN() string {
var b [4]byte
_, _ = rand.Read(b[:])
n := binary.BigEndian.Uint32(b[:])
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprint(n)))
}
// doJSON performs an HTTP request and decodes the JSON response into out. It
// closes the response body and wraps network/decode errors. A nil out skips
// decoding (used for PUTs whose body is opaque bytes).
func (a *Adapter) doJSON(ctx context.Context, method, rawURL string, body io.Reader, out any) error {
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return fmt.Errorf("weixin_ilink: build %s %s: %w", method, rawURL, err)
}
a.authorizationHeader(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("weixin_ilink: %s %s: %w", method, rawURL, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("weixin_ilink: %s %s: status %d: %s",
method, rawURL, resp.StatusCode, strings.TrimSpace(string(msg)))
}
if out == nil {
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
return fmt.Errorf("weixin_ilink: drain %s %s: %w", method, rawURL, err)
}
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("weixin_ilink: decode %s %s: %w", method, rawURL, err)
}
return nil
}
// apiURL joins a path with the configured base URL.
func (a *Adapter) apiURL(path string) string {
return joinURL(a.cfg.BaseURL, path)
}
// joinURL joins a base URL with a leading-slash path, tolerating a trailing slash
// on the base.
func joinURL(base, path string) string {
return strings.TrimRight(base, "/") + path
}
// pollBase returns the base URL for get_qrcode_status: the IDC redirect host when
// one was recorded (scaned_but_redirect), else the configured base URL.
func (a *Adapter) pollBase() string {
a.mu.RLock()
defer a.mu.RUnlock()
if a.pollBaseURL != "" {
return a.pollBaseURL
}
return a.cfg.BaseURL
}
// setPollBaseURL records an IDC redirect host (https://<redirect_host>) for
// subsequent get_qrcode_status polls, per the official currentApiBaseUrl logic.
// An empty host clears the override.
func (a *Adapter) setPollBaseURL(host string) {
a.mu.Lock()
defer a.mu.Unlock()
if host == "" {
a.pollBaseURL = ""
return
}
a.pollBaseURL = "https://" + host
}
// buildClientVersion encodes a semantic version into the iLink App-ClientVersion
// wire value 0x00MMNNPP = (major&0xff)<<16 | (minor&0xff)<<8 | (patch&0xff), as
// the official api.ts does. e.g. "2.4.8" -> "132104" (a decimal string, not "1").
func buildClientVersion(version string) string {
parts := strings.Split(version, ".")
at := func(i int) int {
if i >= len(parts) {
return 0
}
n, err := strconv.Atoi(parts[i])
if err != nil {
return 0
}
return n
}
v := ((at(0) & 0xff) << 16) | ((at(1) & 0xff) << 8) | (at(2) & 0xff)
return strconv.Itoa(v)
}
// cdnURL joins a path with the configured CDN base URL, escaping raw query
// strings that some CDN references carry.
func (a *Adapter) cdnURL(raw string) string {
if u, err := url.Parse(raw); err == nil && u.IsAbs() {
return raw
}
return strings.TrimRight(a.cfg.CDNBaseURL, "/") + "/" + strings.TrimLeft(raw, "/")
}
@@ -159,6 +159,22 @@ func TestAdapter_SetMessageHandler_And_RecordInbound(t *testing.T) {
}
}
func TestBuildClientVersion(t *testing.T) {
t.Parallel()
cases := map[string]string{
"2.4.8": "132104", // official @tencent-weixin/openclaw-weixin 2.4.8
"1.0.11": "65547", // official comment example 0x0001000B
"1.2.3": "66051",
"0.0.0": "0",
"bad": "0",
}
for in, want := range cases {
if got := buildClientVersion(in); got != want {
t.Fatalf("buildClientVersion(%q) = %q, want %q", in, got, want)
}
}
}
func TestAdapter_Recipient_ResolvesGroupAndSender(t *testing.T) {
t.Parallel()
tests := []struct {
+18 -14
View File
@@ -30,13 +30,15 @@ type reqRecorder struct {
// recordedReq is a normalized snapshot of an inbound HTTP request.
type recordedReq struct {
method string
path string
query string
body []byte
auth string
authType string
uin string
method string
path string
query string
body []byte
auth string
authType string
uin string
appID string
appClientVer string
}
func (r *reqRecorder) add(req recordedReq) {
@@ -102,13 +104,15 @@ func newJSONServer(t *testing.T, rec *reqRecorder, fn func(r recordedReq) handle
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
req := recordedReq{
method: r.Method,
path: r.URL.Path,
query: r.URL.RawQuery,
body: body,
auth: r.Header.Get("Authorization"),
authType: r.Header.Get("AuthorizationType"),
uin: r.Header.Get("X-WECHAT-UIN"),
method: r.Method,
path: r.URL.Path,
query: r.URL.RawQuery,
body: body,
auth: r.Header.Get("Authorization"),
authType: r.Header.Get("AuthorizationType"),
uin: r.Header.Get("X-WECHAT-UIN"),
appID: r.Header.Get("iLink-App-Id"),
appClientVer: r.Header.Get("iLink-App-ClientVersion"),
}
if rec != nil {
rec.add(req)
+77 -6
View File
@@ -1,7 +1,9 @@
package weixin_ilink
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -29,6 +31,9 @@ type qrcodeStatusResp struct {
BotID string `json:"ilink_bot_id"`
UserID string `json:"ilink_user_id"`
BaseURL string `json:"baseurl"`
// RedirectHost carries the IDC host to poll next when status is
// "scaned_but_redirect" (official currentApiBaseUrl redirect).
RedirectHost string `json:"redirect_host"`
}
// login is the Connect-side scan-code binding (design/08 §4). It joins (or
@@ -81,10 +86,37 @@ func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
}
return
}
if status.Status == "confirmed" && status.BotToken != "" {
switch status.Status {
case "confirmed":
if status.BotToken == "" {
a.failLogin(sess, fmt.Errorf("weixin_ilink: login confirmed but no bot_token returned"))
return
}
a.completeLogin(sess, status)
return
case "binded_redirect":
// The scanned bot is already bound to this instance; no new credentials
// are issued. Treat it as an already-bound (successful) outcome.
a.completeBindedRedirect(sess)
return
case "verify_code_blocked":
a.failLogin(sess, fmt.Errorf("weixin_ilink: verify code blocked, too many failed attempts"))
return
case "scaned_but_redirect":
if status.RedirectHost != "" {
a.setPollBaseURL(status.RedirectHost)
a.log.Info("weixin_ilink: idc redirect, switching polling host", "host", status.RedirectHost)
} else {
a.log.Warn("weixin_ilink: scaned_but_redirect without redirect_host, continuing on current host")
}
case "need_verifycode":
// The server asks for a pairing code shown in the phone WeChat. Expose
// the state and a hint so the panel can prompt the user; the code itself
// is not yet plumbed back to the poll (see TODO).
a.setLoginState(LoginStatusNeedVerifyCode, loginNeedVerifyCodeHint)
}
// wait / scaned / expired / need_verifycode / scaned_but_redirect keep
// polling until confirmed or the window elapses.
select {
case <-timeoutCtx.Done():
a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err()))
@@ -119,6 +151,25 @@ func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) {
sess.done <- nil
}
// completeBindedRedirect treats binded_redirect as an already-bound outcome: the
// scanned bot is already linked to this instance, so the server issues no new
// credentials and any existing token remains valid.
func (a *Adapter) completeBindedRedirect(sess *loginSession) {
a.mu.Lock()
a.connected = a.cfg.BotToken != ""
a.loginStatus = LoginStatusConfirmed
a.loginDetail = loginBindedRedirectDetail
a.mu.Unlock()
a.loginMu.Lock()
if a.loginSession == sess {
a.loginSession = nil
}
a.loginMu.Unlock()
sess.done <- nil
}
// failLogin records a failed scan-login and signals the waiting Connect path.
func (a *Adapter) failLogin(sess *loginSession, err error) {
a.setLoginState(LoginStatusFailed, err.Error())
@@ -132,11 +183,30 @@ func (a *Adapter) failLogin(sess *loginSession, err error) {
sess.done <- err
}
// fetchQRCode requests a new bot login QR code.
// localTokenList returns the locally-held bot tokens to advertise on
// get_bot_qrcode, so an already-bound bot can be detected (binded_redirect). On a
// fresh bind it returns an empty (non-nil) slice so the wire carries `[]` rather
// than `null`, matching the official client.
func (a *Adapter) localTokenList() []string {
a.mu.RLock()
defer a.mu.RUnlock()
if a.cfg.BotToken == "" {
return []string{}
}
return []string{a.cfg.BotToken}
}
// fetchQRCode requests a new bot login QR code. The official client issues this as
// a POST carrying local_token_list so the server can detect an existing bind and
// answer binded_redirect instead of a fresh code.
func (a *Adapter) fetchQRCode(ctx context.Context) (qrcodeResp, error) {
path := a.apiURL("/ilink/bot/get_bot_qrcode?bot_type=" + strconv.Itoa(botType))
body, err := json.Marshal(map[string]any{"local_token_list": a.localTokenList()})
if err != nil {
return qrcodeResp{}, fmt.Errorf("weixin_ilink: marshal get_bot_qrcode body: %w", err)
}
var out qrcodeResp
if err := a.doJSON(ctx, http.MethodGet, path, nil, &out); err != nil {
if err := a.doJSON(ctx, http.MethodPost, path, bytes.NewReader(body), &out); err != nil {
return qrcodeResp{}, err
}
if out.Qrcode == "" {
@@ -145,11 +215,12 @@ func (a *Adapter) fetchQRCode(ctx context.Context) (qrcodeResp, error) {
return out, nil
}
// pollQRStatus checks whether a QR code has been confirmed.
// pollQRStatus checks whether a QR code has been confirmed. It polls the IDC
// redirect host if one was recorded (scaned_but_redirect), else the base URL.
func (a *Adapter) pollQRStatus(ctx context.Context, qrcode string) (qrcodeStatusResp, error) {
path := a.apiURL("/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode))
path := "/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)
var out qrcodeStatusResp
if err := a.doJSON(ctx, http.MethodGet, path, nil, &out); err != nil {
if err := a.doJSON(ctx, http.MethodGet, joinURL(a.pollBase(), path), nil, &out); err != nil {
return qrcodeStatusResp{}, err
}
return out, nil
+15 -1
View File
@@ -8,10 +8,10 @@ package weixin_ilink
// concurrent QR binds.
import (
"time"
"context"
"errors"
"fmt"
"time"
)
// Login status values reported by Adapter.LoginStatus.
@@ -26,6 +26,17 @@ const (
LoginStatusConfirmed = "confirmed"
// LoginStatusFailed means the last scan/login attempt errored or timed out.
LoginStatusFailed = "failed"
// LoginStatusNeedVerifyCode means the WeChat server asks for a pairing code
// shown on the phone WeChat; the panel should prompt the user to enter it.
LoginStatusNeedVerifyCode = "need_verifycode"
)
// loginNeedVerifyCodeHint is the detail LoginStatus reports for need_verifycode so
// the panel can prompt the user; loginBindedRedirectDetail is what a binded_redirect
// outcome reports.
const (
loginNeedVerifyCodeHint = "请输入微信显示的数字"
loginBindedRedirectDetail = "已连接过此 OpenClaw,无需重复连接"
)
// ErrAlreadyLoggedIn is returned by StartLogin when a bot token is already
@@ -99,6 +110,9 @@ func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) {
}
a.loginSession = sess
a.setLoginState(LoginStatusWaiting, "")
// Reset any IDC redirect host recorded by a previous (re-)scan so a new login
// starts polling the configured base URL.
a.setPollBaseURL("")
// Log only the renderable URL, never the raw qrcode (it is a secret).
a.log.Info("scan the qr code to bind the bot", "url", raw.QrcodeImgContent)
@@ -3,6 +3,7 @@ package weixin_ilink
import (
"context"
"errors"
"net/http"
"testing"
"time"
)
@@ -48,6 +49,16 @@ func TestStartLogin_WaitThenConfirm(t *testing.T) {
if got := ad.Config()["bot_token"]; got != "tok-x" {
t.Fatalf("bot_token = %v, want tok-x", got)
}
// The QR mint must be a POST carrying the iLink identify headers per the
// official protocol.
for _, q := range rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" }) {
if q.method != http.MethodPost {
t.Fatalf("get_bot_qrcode method = %q, want POST", q.method)
}
if q.appID != "bot" || q.appClientVer != "132104" {
t.Fatalf("get_bot_qrcode iLink headers = (%q, %q), want (bot, 132104)", q.appID, q.appClientVer)
}
}
}
// TestStartLogin_IdempotentWhileWaiting verifies a second StartLogin while a login
@@ -0,0 +1,190 @@
package weixin_ilink
import (
"bytes"
"context"
"io"
"net/http"
"sync"
"testing"
"time"
)
// scriptedTransport answers the weixin_ilink endpoints without network I/O. It
// records the host each get_qrcode_status poll hits so tests can assert the IDC
// redirect routing, and returns get_bot_qrcode with a fixed QR plus get_qrcode_status
// bodies from statusFn (n is the 1-based poll count).
type scriptedTransport struct {
qr []byte
statusFn func(n int) []byte
mu sync.Mutex
statusHosts []string
}
func (t *scriptedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var body []byte
switch req.URL.Path {
case "/ilink/bot/get_bot_qrcode":
body = t.qr
case "/ilink/bot/get_qrcode_status":
t.mu.Lock()
n := len(t.statusHosts) + 1
t.statusHosts = append(t.statusHosts, req.URL.Host)
t.mu.Unlock()
body = t.statusFn(n)
default:
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewReader(nil)),
Header: http.Header{},
Request: req,
}, nil
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
Request: req,
}, nil
}
func (t *scriptedTransport) hosts() []string {
t.mu.Lock()
defer t.mu.Unlock()
return append([]string(nil), t.statusHosts...)
}
// newScriptedAdapter builds an Adapter wired to a scripted transport (no real
// network), with a base URL that does not resolve — the transport intercepts it.
func newScriptedAdapter(cfg Config, tr http.RoundTripper) (*Adapter, error) {
return New(cfg, &http.Client{Transport: tr}, nil)
}
const scriptedQR = `{"qrcode":"qr-r","qrcode_img_content":"https://img/r"}`
const scriptedBaseURL = "https://ilinkai.weixin.qq.com"
// TestStartLogin_ScanButRedirectRoutesPoll verifies that a scaned_but_redirect
// response with a redirect_host switches subsequent get_qrcode_status polls to
// the https://<redirect_host> IDC host.
func TestStartLogin_ScanButRedirectRoutesPoll(t *testing.T) {
t.Parallel()
tr := &scriptedTransport{
qr: []byte(scriptedQR),
statusFn: func(n int) []byte {
if n == 1 {
return []byte(`{"status":"scaned_but_redirect","redirect_host":"idc.weixin.qq.com"}`)
}
return []byte(`{"status":"confirmed","bot_token":"tok-x","ilink_bot_id":"bot-x","ilink_user_id":"o-1","baseurl":"https://ilinkai.weixin.qq.com"}`)
},
}
ad, err := newScriptedAdapter(Config{
BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second,
}, tr)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusConfirmed
})
if got := ad.Config()["bot_token"]; got != "tok-x" {
t.Fatalf("bot_token = %v, want tok-x", got)
}
hosts := tr.hosts()
if len(hosts) < 2 {
t.Fatalf("status polls = %d, want >= 2 (first on base, second on redirect host)", len(hosts))
}
if hosts[0] != "ilinkai.weixin.qq.com" {
t.Fatalf("first status poll host = %q, want ilinkai.weixin.qq.com", hosts[0])
}
if hosts[1] != "idc.weixin.qq.com" {
t.Fatalf("second status poll host = %q, want idc.weixin.qq.com (IDC redirect)", hosts[1])
}
}
// TestStartLogin_VerifyCodeBlocked verifies verify_code_blocked transitions the
// login to failed.
func TestStartLogin_VerifyCodeBlocked(t *testing.T) {
t.Parallel()
tr := &scriptedTransport{
qr: []byte(scriptedQR),
statusFn: func(int) []byte { return []byte(`{"status":"verify_code_blocked"}`) },
}
ad, err := newScriptedAdapter(Config{
BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 2 * time.Second,
}, tr)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusFailed
})
if ad.IsConnected() {
t.Fatal("IsConnected() = true after verify_code_blocked")
}
}
// TestStartLogin_BindedRedirect verifies binded_redirect is treated as already
// bound (LoginStatus confirmed) rather than a failure.
func TestStartLogin_BindedRedirect(t *testing.T) {
t.Parallel()
tr := &scriptedTransport{
qr: []byte(scriptedQR),
statusFn: func(int) []byte { return []byte(`{"status":"binded_redirect"}`) },
}
ad, err := newScriptedAdapter(Config{
BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 2 * time.Second,
}, tr)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusConfirmed
})
if _, detail := ad.LoginStatus(context.Background()); detail != loginBindedRedirectDetail {
t.Fatalf("binded_redirect detail = %q, want %q", detail, loginBindedRedirectDetail)
}
}
// TestStartLogin_NeedVerifyCode verifies need_verifycode surfaces a dedicated
// LoginStatus sub-state with a hint detail, then times out to failed.
func TestStartLogin_NeedVerifyCode(t *testing.T) {
t.Parallel()
tr := &scriptedTransport{
qr: []byte(scriptedQR),
statusFn: func(int) []byte { return []byte(`{"status":"need_verifycode"}`) },
}
ad, err := newScriptedAdapter(Config{
BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 40 * time.Millisecond,
}, tr)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusNeedVerifyCode
})
if _, detail := ad.LoginStatus(context.Background()); detail != loginNeedVerifyCodeHint {
t.Fatalf("need_verifycode detail = %q, want %q", detail, loginNeedVerifyCodeHint)
}
// Without a verify-code回传 path the flow eventually times out to failed.
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusFailed
})
}
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"net/http"
"testing"
"time"
)
@@ -78,6 +79,29 @@ func TestStartLogin_ScanStatusTransition(t *testing.T) {
t.Fatalf("status poll AuthorizationType = %q, want ilink_bot_token", q.authType)
}
}
// Confirm the QR mint follows the official protocol: POST, the iLink
// App identify headers, and a local_token_list body (empty on a fresh bind).
for _, q := range rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" }) {
if q.method != http.MethodPost {
t.Fatalf("get_bot_qrcode method = %q, want POST", q.method)
}
if q.appID != "bot" {
t.Fatalf("get_bot_qrcode iLink-App-Id = %q, want bot", q.appID)
}
if q.appClientVer != "132104" {
t.Fatalf("get_bot_qrcode iLink-App-ClientVersion = %q, want 132104", q.appClientVer)
}
if q.query != "bot_type=3" {
t.Fatalf("get_bot_qrcode query = %q, want bot_type=3", q.query)
}
ltl, ok := q.bodyJSON()["local_token_list"]
if !ok {
t.Fatalf("get_bot_qrcode body missing local_token_list: %s", q.body)
}
if ltl == nil {
t.Fatalf("get_bot_qrcode local_token_list = nil, want empty array []")
}
}
}
// TestStartLogin_Timeout verifies the bind aborts with ErrLoginTimeout when the QR