diff --git a/plugins/channels/weixin_ilink/adapter.go b/plugins/channels/weixin_ilink/adapter.go index b7a94f4..23a4a28 100644 --- a/plugins/channels/weixin_ilink/adapter.go +++ b/plugins/channels/weixin_ilink/adapter.go @@ -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. diff --git a/plugins/channels/weixin_ilink/adapter_http.go b/plugins/channels/weixin_ilink/adapter_http.go new file mode 100644 index 0000000..a87e19d --- /dev/null +++ b/plugins/channels/weixin_ilink/adapter_http.go @@ -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://) 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, "/") +} diff --git a/plugins/channels/weixin_ilink/adapter_test.go b/plugins/channels/weixin_ilink/adapter_test.go index fabd908..0c9aea3 100644 --- a/plugins/channels/weixin_ilink/adapter_test.go +++ b/plugins/channels/weixin_ilink/adapter_test.go @@ -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 { diff --git a/plugins/channels/weixin_ilink/fake_test.go b/plugins/channels/weixin_ilink/fake_test.go index 2cffbb4..97ca5a6 100644 --- a/plugins/channels/weixin_ilink/fake_test.go +++ b/plugins/channels/weixin_ilink/fake_test.go @@ -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) diff --git a/plugins/channels/weixin_ilink/login.go b/plugins/channels/weixin_ilink/login.go index 7eee6d1..7f475ae 100644 --- a/plugins/channels/weixin_ilink/login.go +++ b/plugins/channels/weixin_ilink/login.go @@ -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 diff --git a/plugins/channels/weixin_ilink/login_admin.go b/plugins/channels/weixin_ilink/login_admin.go index 8650833..57b59c9 100644 --- a/plugins/channels/weixin_ilink/login_admin.go +++ b/plugins/channels/weixin_ilink/login_admin.go @@ -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) diff --git a/plugins/channels/weixin_ilink/login_admin_test.go b/plugins/channels/weixin_ilink/login_admin_test.go index d01dd2b..15aa449 100644 --- a/plugins/channels/weixin_ilink/login_admin_test.go +++ b/plugins/channels/weixin_ilink/login_admin_test.go @@ -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 diff --git a/plugins/channels/weixin_ilink/login_state_test.go b/plugins/channels/weixin_ilink/login_state_test.go new file mode 100644 index 0000000..dc2f0f8 --- /dev/null +++ b/plugins/channels/weixin_ilink/login_state_test.go @@ -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:// 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 + }) +} diff --git a/plugins/channels/weixin_ilink/login_test.go b/plugins/channels/weixin_ilink/login_test.go index da4880a..31f393f 100644 --- a/plugins/channels/weixin_ilink/login_test.go +++ b/plugins/channels/weixin_ilink/login_test.go @@ -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