Files
Cyrene_v2/internal/channel/adapter/httpclient/client.go
T
AskaEth 996201c4ed fix(httpclient): 信封 message 默认不进错误,渠道上游自由文本一并收口
信封路径(非 2xx + 信封体、2xx + code!=0)此前把上游 message 经 RedactText
拼进 error,而上游会在 message 里回显被拒的请求正文 —— 她的原话因此进了错误
与调用方日志(M49 探针实测:code 1: invalid content: 她刚才说的那句话 ...)。

- envelope message 改为**默认不带**(新 opt-in WithEnvelopeMessage(),与
  trace.UpstreamError 的 message 同一口径);code 与上游 status 保留,
  errors.Is 的 ErrAPI / trace.ErrUpstream 链不变。
- opt-in 下统一走单一清洗 upstreamText:压平空白 + trace.RedactTextLimited,
  上限 40 rune(比 trace 的 128 短:错误里那句话是提示,不是摘要)。
- plugins/channels 同族出口一并收口(扫过全部,命中 4 处):
  telegram description、qq_obv11 msg(错误 + logEchoFailure 日志)、
  weixin_ilink CDN x-error-message;qq 的 status 字段改走 trace.ErrorToken
  令牌守卫。被剥掉的一律由 status / error_code / retcode 承担分类。

每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
2026-10-26 19:28:15 +08:00

252 lines
10 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 httpclient provides a small, reusable HTTP (/JSON) client for IM
// channel adapters that talk to a REST API with token auth and a
// common `{code,message,data}` response envelope (KOOK, and similarly shaped
// platforms). It is used by the KOOK adapter and is intended to be shared by
// future platforms; existing adapters (Telegram, OneBot) that already carry
// their own transport are left untouched.
//
// It depends only on the standard library, internal/logger and internal/runtime/trace.
// The auth token is never logged or surfaced in errors. The Authorization scheme is
// configurable (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot").
//
// The envelope's message is an upstream free-text channel: upstreams echo the rejected
// request body into it, and that body holds what she said. It is therefore **not**
// carried in errors by default (see WithEnvelopeMessage); code and HTTP status are. The
// same rule — code/status stay, free text goes — applies to every channel adapter's own
// envelope handling (Telegram description, OneBot msg, iLink CDN x-error-message); see
// upstream_text.go.
package httpclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// ErrAPI is the sentinel returned when the remote API reports a non-zero code or
// a non-2xx HTTP status. Tests compare with errors.Is.
var ErrAPI = errors.New("httpclient: api error")
// maxBodyBytes caps how large a response body the client will read.
const maxBodyBytes = 1 << 20 // 1 MiB
// Client is a token-auth JSON HTTP client bound to a base URL.
type Client struct {
http *http.Client
baseURL string
token string
authScheme string
log *slog.Logger
// keepEnvelopeMessage 是**默认关闭**的开关:信封 message 只有显式 opt-in
// (WithEnvelopeMessage)才会进错误文本。零值 = 安全,见该函数的注释。
keepEnvelopeMessage bool
}
// Option customizes a Client at construction time.
type Option func(*Client)
// WithHTTPClient injects the *http.Client used for requests. Tests pass the
// client of an httptest.Server.
func WithHTTPClient(h *http.Client) Option {
return func(c *Client) { c.http = h }
}
// WithBaseURL sets the URL root that paths are joined to. A base ending in "/"
// has its trailing slash trimmed.
func WithBaseURL(base string) Option {
return func(c *Client) { c.baseURL = strings.TrimRight(base, "/") }
}
// WithToken permits an Authorization header on every request. The token is
// stored in memory only and never logged.
func WithToken(token string) Option {
return func(c *Client) { c.token = token }
}
// WithAuthScheme sets the scheme used in the `Authorization: <scheme> <token>`
// header. The default is "Bearer" (OAuth2-style tokens). KOOK robots require
// "Bot". Passing an empty string keeps the default.
func WithAuthScheme(scheme string) Option {
return func(c *Client) { c.authScheme = strings.TrimSpace(scheme) }
}
// WithLogger injects the logger. A nil logger stays silent.
func WithLogger(l *slog.Logger) Option {
return func(c *Client) {
if l == nil {
c.log = logger.New(logger.Options{Out: io.Discard})
} else {
c.log = l
}
}
}
// WithEnvelopeMessage 显式要求把信封的 message 保留在错误文本里。**默认不带**。
//
// 🔴 默认不带是刻意的,理由与 trace.UpstreamError 的 message 同源:上游会把被拒的输入
// 回显在 message 里(KOOK 实测 `invalid content: <她原话>`),于是那句话会随错误进
// 调用方的日志。排障真正用得到的分类信息是 `code` 与 `status`,两者**恒在**;message
// 只是人话提示。宁可少一条提示,也不让她的原话进日志。
//
// 只有在你**确认该上游不回显用户内容**时才打开。打开后 message 仍会压平空白、清洗
// 凭据形状并截到 maxUpstreamTextRunes(40 rune)—— 上限不像 trace 的 128,是这层没有
// 「一屏摘要」的诉求,短到装不下一句正文。
//
// 函数式选项的形状让最常用的 `New(...)` 天然就是最安全的那个:想放宽必须在这里显式
// 写出来,代码评审时一眼可见(与 trace.WithUpstreamMessage 同一个范式)。
func WithEnvelopeMessage() Option {
return func(c *Client) { c.keepEnvelopeMessage = true }
}
// New builds a Client. With no base URL the caller must pass absolute URLs (via
// the Do/Get helpers' path being an absolute URL).
func New(opts ...Option) *Client {
c := &Client{
http: &http.Client{},
authScheme: "Bearer",
log: logger.New(logger.Options{Out: io.Discard}),
}
for _, opt := range opts {
opt(c)
}
return c
}
// APIEnvelope is the common `{code,message,data}` response envelope used by the
// KOOK REST API. Code==0 means success; a non-zero code is mapped to ErrAPI.
type APIEnvelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
// DoJSON performs a request with the given method against path (joined to the
// base URL) and returns the decoded data payload of the envelope. A non-zero
// code or a non-2xx status is wrapped with ErrAPI. A 2xx body that is not an
// envelope (e.g. a raw payload) is returned verbatim as data.
func (c *Client) DoJSON(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
return c.do(ctx, method, c.resolve(path), body)
}
// GetJSON performs a GET against path with the given query parameters and
// returns the decoded data payload. A nil query adds nothing.
func (c *Client) GetJSON(ctx context.Context, path string, query url.Values) (json.RawMessage, error) {
target := c.resolve(path)
if len(query) > 0 {
target += "?" + query.Encode()
}
return c.do(ctx, http.MethodGet, target, nil)
}
// resolve joins path to the base URL, passing through an absolute path as-is.
func (c *Client) resolve(path string) string {
if c.baseURL == "" {
return path
}
return c.baseURL + path
}
// do performs the request, injecting auth and decoding the envelope.
//
// 地址在这一层只以来源形态出镜:target 由调用方给(GetJSON 的 query 可能带凭据、
// 绝对 path 可能带 user:pass@),完整串既不进错误也不进日志。响应体原文同理 ——
// 上游可能把被拒的输入回显在体里,因此 4xx/5xx 只收敛成状态码 + 信封的 code
// (message 默认不带,见 WithEnvelopeMessage),其余字节一概不留。
func (c *Client) do(ctx context.Context, method, target string, body any) (json.RawMessage, error) {
host := trace.HostOf(target)
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
return nil, fmt.Errorf("httpclient: encode %s request: %w", method, err)
}
}
req, err := http.NewRequestWithContext(ctx, method, target, &buf)
if err != nil {
return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, host, trace.URLCause(err))
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.token != "" {
scheme := c.authScheme
if scheme == "" {
scheme = "Bearer"
}
req.Header.Set("Authorization", scheme+" "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("httpclient: %s %s: %w", method, host, trace.URLCause(err))
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, host, trace.URLCause(err))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, c.apiFailure(method, host, resp, raw)
}
var envelope APIEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return raw, nil
}
if envelope.Code != 0 {
if detail := c.envelopeDetail(envelope.Message); detail != "" {
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w",
method, host, envelope.Code, detail, ErrAPI)
}
return nil, fmt.Errorf("httpclient: %s %s: code %d: %w", method, host, envelope.Code, ErrAPI)
}
return envelope.Data, nil
}
// apiFailure 把一次非 2xx 响应收敛成一条不携带响应体原文的错误:来源主机 + 状态码,
// 外加(当体确实是 `{code,message}` 信封时)code —— message 同样只在显式 opt-in 时
// 才出现。其余字节只经 trace.UpstreamError 留下体积与 sha256 前缀,供「是不是同一份
// 报文」这类相关性判断。
func (c *Client) apiFailure(method, host string, resp *http.Response, raw []byte) error {
upstream := trace.UpstreamError(host, resp.StatusCode, resp.Header.Get("Content-Type"), raw)
var envelope APIEnvelope
if err := json.Unmarshal(raw, &envelope); err == nil {
detail := c.envelopeDetail(envelope.Message)
switch {
case envelope.Code != 0 && detail != "":
return fmt.Errorf("httpclient: %s %s: code %d: %s: %w: %w",
method, host, envelope.Code, detail, upstream, ErrAPI)
case envelope.Code != 0:
return fmt.Errorf("httpclient: %s %s: code %d: %w: %w", method, host, envelope.Code, upstream, ErrAPI)
case detail != "":
return fmt.Errorf("httpclient: %s %s: %s: %w: %w", method, host, detail, upstream, ErrAPI)
}
}
return fmt.Errorf("httpclient: %s: %w: %w", method, upstream, ErrAPI)
}
// envelopeDetail 返回**允许进错误文本**的信封 message:默认空串(不带)。
//
// 为什么默认不带(这条取舍是实测得出的):上游会把被拒的请求正文回显在 message 里,
// 而那条正文就是她的话。M49 复审的探针在隔离副本里拿到的实际文本是
// `... code 1: invalid content: 她刚才说的那句话: ...: status=400` —— 她的原话既进了
// 错误也进了调用方的日志。排障需要的是 code 与 status(两者恒在),不是那句话。
//
// 只有 WithEnvelopeMessage() 显式打开后,这里才返回 upstreamText(压平空白 + 凭据打码
// + 截到 40 rune)。
func (c *Client) envelopeDetail(message string) string {
if !c.keepEnvelopeMessage {
return ""
}
return upstreamText(message)
}