feat(weixin_ilink): 扫码协议与腾讯官方对齐——POST local_token_list + iLink-App-Id/ClientVersion 头 + need_verifycode/redirect 状态机
This commit is contained in:
1 parent
bef398d47a
commit
f5950e4bde
9 files changed
+508
-99
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user