141 lines
4.4 KiB
Go
141 lines
4.4 KiB
Go
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, "/")
|
|
}
|