Files
Cyrene_v2/plugins/channels/weixin_ilink/adapter_http.go
T
AskaEth 3be70f8232
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
- 新增 internal/channel/mediafetch:来源白名单(deny-by-default,支持 *.suffix)
  + 逐跳重定向校验 + 边读边限 + 超时;只回来源主机,绝不回完整 URL
- 四渠道各自 media.go 接通入站内联:只从平台载荷给出的引用取回字节
  (telegram file_id→getFile→file_path 形态校验;kook CDN;qq 文件段 url;
   weixin cdnURL + AES-ECB 解密)
- 常量全部复用既有来源:documents.ExtAllowed / DefaultMaxBytes / MaxPerMessage
- channelbridge 诚实提示文案改为对「渠道只给远程引用」与「代取失败」两种成因都成立
- 18 个测试函数(含四渠道「正文里的 URL ⇒ 0 次命中」红线反证、端到端进模型可见视图)
- 变异验证:去掉尺寸上限 ⇒ 四渠道红(含修正后不再是假绿的 weixin 超量用例)
2026-10-04 22:13:17 +08:00

152 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package weixin_ilink
// 本文件承载 iLink 适配器的 HTTP 管道(请求构造与响应解码)。它从 adapter.go
// 拆出,以便那个文件保持在项目 ≤500 行的单文件上限之内。这里不放任何「包内其余
// 代码或其测试依赖其位于某个特定文件」的导出符号。
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
// ilinkAuthType 是 iLink 请求头 `AuthorizationType` 的协议定值,仅此一处定义。
const ilinkAuthType = "ilink_bot_token"
// 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)。
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 把路径与已配置的基础 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, "/")
}