接 M38 的 18 条真泄漏里「渠道适配器 + 出站工具」这一族(#10 #11 #12 #13 #15 #16)。 只动日志与 error 文字:发送/接收、解析口径、重试次数一个都没改。 - httpclient.do:非 2xx 不再把 512B 响应体原文拼进 error,改 trace.UpstreamError (状态/体积/sha256 前缀)+ 信封 code/message(RedactText + 200 rune);error 里的 target 改 trace.HostOf,并剥掉 *url.Error 外壳(它带整条地址) - telegram doJSON:4xx 只解 error_code/description(清洗 + 截断),体其余字段不拼; 传输错误只留来源 —— Bot API 把 token 写在地址路径里 - weixin_ilink doJSON:4096B 体 ⇒ trace.UpstreamError,rawURL ⇒ mediafetch.HostOf; uploadOnce 剥掉预签名上传地址(encrypted_query_param),重试日志补来源主机 - qq_obv11 connectClient 成功 INFO:改走同包已有的 maskAccessTokenQuery (OneBot 惯例把 ?access_token= 写进 remote_url) - plugins/tools/io http 拦截日志:改记 trace.HostOf + 原因码,不记原 URL、也不记错误 原文(解析失败时它包着 *url.Error) 每条都有能变异打红的用例:QQ/iLink 预签名地址的哨兵、三处 4xx 体哨兵、 user:pass@ 与 query 凭据;八处变异逐条验证变红。
181 lines
6.5 KiB
Go
181 lines
6.5 KiB
Go
package weixin_ilink
|
||
|
||
// 本文件承载 iLink 适配器的 HTTP 管道(请求构造与响应解码)。它从 adapter.go
|
||
// 拆出,以便那个文件保持在项目 ≤500 行的单文件上限之内。这里不放任何「包内其余
|
||
// 代码或其测试依赖其位于某个特定文件」的导出符号。
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// ilinkAuthType 是 iLink 请求头 `AuthorizationType` 的协议定值,仅此一处定义。
|
||
const ilinkAuthType = "ilink_bot_token"
|
||
|
||
// maxErrorBodyBytes 是 iLink 非 2xx 响应里被读取的字节上限:只用来数体积与取
|
||
// sha256 前缀(trace.UpstreamError 的口径),正文不进错误。
|
||
const maxErrorBodyBytes = 4096
|
||
|
||
// authorizationHeader 为 req 装上 iLink 通用请求头:JSON 内容类型、bearer
|
||
// token、认证类型、每次新生成的随机 X-WECHAT-UIN 防重放值,以及 App 标识头。
|
||
func (a *Adapter) authorizationHeader(req *http.Request) {
|
||
for k, vs := range a.inboundHeaders() {
|
||
for _, v := range vs {
|
||
req.Header.Set(k, v)
|
||
}
|
||
}
|
||
}
|
||
|
||
// inboundHeaders 构造 iLink 通用请求头(JSON 内容类型、bearer token、认证类型、随机
|
||
// 防重放值、App 标识头)。API 调用与**入站附件取回**共用同一份口径(切片 IM-1)——
|
||
// CDN 与 API 同属 iLink 平台,凭据与标识没有任何区别。
|
||
func (a *Adapter) inboundHeaders() http.Header {
|
||
h := http.Header{}
|
||
h.Set("Content-Type", "application/json")
|
||
h.Set("AuthorizationType", ilinkAuthType)
|
||
h.Set("X-WECHAT-UIN", randomUIN())
|
||
h.Set("iLink-App-Id", ilinkAppID)
|
||
h.Set("iLink-App-ClientVersion", buildClientVersion(ilinkVersion))
|
||
a.mu.RLock()
|
||
token := a.cfg.BotToken
|
||
a.mu.RUnlock()
|
||
if token != "" {
|
||
h.Set("Authorization", "Bearer "+token)
|
||
}
|
||
return h
|
||
}
|
||
|
||
// randomUIN 返回 base64 编码的十进制 uint32,每次请求都新生成,作为 iLink 的
|
||
// 防重放值。
|
||
func randomUIN() string {
|
||
var b [4]byte
|
||
_, _ = rand.Read(b[:])
|
||
n := binary.BigEndian.Uint32(b[:])
|
||
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprint(n)))
|
||
}
|
||
|
||
// doJSON 发起一次 HTTP 请求并把 JSON 响应解码进 out。它关闭响应体,并用 %w
|
||
// 包裹网络/解码错误。out 为 nil 时跳过解码(用于响应体是不透明字节的 PUT)。
|
||
//
|
||
// 地址只以来源形态出镜(mediafetch.HostOf):rawURL 由 base+path 拼成,而 pollBase
|
||
// 可能是对端重定向给的主机,完整串没有进日志的必要。非 2xx 的响应体同样不拼 ——
|
||
// iLink 会在 errmsg 里回显请求内容,这里只经 trace.UpstreamError 留下
|
||
// 状态码/体积/sha256 前缀(同 M40 口径)。
|
||
func (a *Adapter) doJSON(ctx context.Context, method, rawURL string, body io.Reader, out any) error {
|
||
host := mediafetch.HostOf(rawURL)
|
||
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
|
||
if err != nil {
|
||
return fmt.Errorf("weixin_ilink: build %s %s: %w", method, host, urlFreeCause(err))
|
||
}
|
||
a.authorizationHeader(req)
|
||
resp, err := a.httpClient.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("weixin_ilink: %s %s: %w", method, host, urlFreeCause(err))
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
|
||
return fmt.Errorf("weixin_ilink: %s %s: %w", method, host,
|
||
trace.UpstreamError(host, resp.StatusCode, resp.Header.Get("Content-Type"), msg))
|
||
}
|
||
if out == nil {
|
||
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
|
||
return fmt.Errorf("weixin_ilink: drain %s %s: %w", method, host, urlFreeCause(err))
|
||
}
|
||
return nil
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||
return fmt.Errorf("weixin_ilink: decode %s %s: %w", method, host, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// urlFreeCause 剥掉 net/http 的 *url.Error 外壳:它会把**整条地址**拼进错误串 ——
|
||
// doJSON 的地址、uploadCiphertext 的预签名上传地址(`encrypted_query_param` 就是下载
|
||
// 凭据)都会跟着进错误与日志。剥壳只丢地址,内层哨兵链(context 取消、*net.DNSError
|
||
// 等)原样保留,因此 fetchErrCategory 的归类不受影响。
|
||
//
|
||
// 与 internal/channel/mediafetch 的 sanitizeErr 同一口径:那份是未导出符号,且只服务
|
||
// 于入站取回路径(mediafetch 不感知本包的发送侧),因此这里保留一份同形的三行实现。
|
||
func urlFreeCause(err error) error {
|
||
var uerr *url.Error
|
||
if errors.As(err, &uerr) && uerr.Err != nil {
|
||
return uerr.Err
|
||
}
|
||
return err
|
||
}
|
||
|
||
// apiURL 把路径与已配置的基础 URL 拼接起来。
|
||
func (a *Adapter) apiURL(path string) string {
|
||
return joinURL(a.cfg.BaseURL, path)
|
||
}
|
||
|
||
// joinURL 把基础 URL 与以斜杠开头的路径拼接起来,并容忍基础 URL 末尾带斜杠。
|
||
func joinURL(base, path string) string {
|
||
return strings.TrimRight(base, "/") + path
|
||
}
|
||
|
||
// pollBase 返回 get_qrcode_status 使用的基础 URL:已记录 IDC 重定向主机
|
||
// (scaned_but_redirect)时用它,否则用已配置的基础 URL。
|
||
func (a *Adapter) pollBase() string {
|
||
a.mu.RLock()
|
||
defer a.mu.RUnlock()
|
||
if a.pollBaseURL != "" {
|
||
return a.pollBaseURL
|
||
}
|
||
return a.cfg.BaseURL
|
||
}
|
||
|
||
// setPollBaseURL 按官方 currentApiBaseUrl 的逻辑,为后续的 get_qrcode_status
|
||
// 轮询记录一个 IDC 重定向主机(https://<redirect_host>)。host 为空则清除该覆盖。
|
||
func (a *Adapter) setPollBaseURL(host string) {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
if host == "" {
|
||
a.pollBaseURL = ""
|
||
return
|
||
}
|
||
a.pollBaseURL = "https://" + host
|
||
}
|
||
|
||
// buildClientVersion 按官方 api.ts 的做法,把语义化版本编码成 iLink
|
||
// App-ClientVersion 的线上值 0x00MMNNPP = (major&0xff)<<16 | (minor&0xff)<<8 |
|
||
// (patch&0xff)。例如 "2.4.8" -> "132104"(十进制字符串,而不是 "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 把路径与已配置的 CDN 基础 URL 拼接起来,并转义部分 CDN 引用携带的原始
|
||
// 查询串。
|
||
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, "/")
|
||
}
|