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.
|
||||
Reference in new issue
Block a user