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.