Files
Cyrene_v2/internal/channel/adapter/httpclient/client.go
T
AskaEth 4b4173dd42 fix(channel): M41 r1 复审三条 —— telegram 构造分支、QQ 改 MaskQuery、删两处死上限
P2-1 telegram doJSON 的**请求构造**失败分支:http.NewRequestWithContext 解析失败的
err 同样是 *url.Error(里面是整条 /bot<token>/… 地址),而 poll.go 的 long poll
failed 会原样落日志。改成与传输层分支同形:trace.HostOf(endpoint) + callCause。

P2-2 qq_obv11 connectClient 的成功 INFO 从 maskAccessTokenQuery 换成
trace.MaskQuery:后者认得 access%5Ftoken / token / api_key / 签名参数 / userinfo,
且保留 EscapedPath(路径可读)与不截断。maskAccessTokenQuery 仍被
sanitizeConnectReason 使用,未成死代码。

P2-3 删掉 maxEnvelopeMessageRunes / maxDescriptionRunes(各 200):trace.RedactText
内部已 truncate 到 maxSummaryRunes=80,这两层永不命中,注释口径与实际不符。改为
直接 return trace.RedactText(压平空白),注释写明 80 rune 由 RedactText 负责。

用例(全部变异打红,逐条验证):telegram 构造分支 token、QQ 八形态凭据矩阵
(含四种 maskAccessTokenQuery 漏掉的形态 + 路径可读反向断言)、两处 80 rune 上限。
2026-10-26 13:58:19 +08:00

225 lines
8.2 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 and internal/logger. 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").
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
}
// 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
}
}
}
// 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(清洗并截断),其余字节一概不留。
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, transportCause(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, transportCause(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, transportCause(err))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, apiFailure(method, host, resp, raw)
}
var envelope APIEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return raw, nil
}
if envelope.Code != 0 {
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w",
method, host, envelope.Code, envelopeMessage(envelope.Message), ErrAPI)
}
return envelope.Data, nil
}
// apiFailure 把一次非 2xx 响应收敛成一条不携带响应体原文的错误:来源主机 + 状态码,
// 外加(当体确实是 `{code,message}` 信封时)清洗并截断过的 code/message。其余字节只
// 经 trace.UpstreamError 留下体积与 sha256 前缀,供「是不是同一份报文」这类相关性判断。
func 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 {
message := envelopeMessage(envelope.Message)
switch {
case envelope.Code != 0 && message != "":
return fmt.Errorf("httpclient: %s %s: code %d: %s: %w: %w",
method, host, envelope.Code, message, upstream, ErrAPI)
case envelope.Code != 0:
return fmt.Errorf("httpclient: %s %s: code %d: %w: %w", method, host, envelope.Code, upstream, ErrAPI)
case message != "":
return fmt.Errorf("httpclient: %s %s: %s: %w: %w", method, host, message, upstream, ErrAPI)
}
}
return fmt.Errorf("httpclient: %s: %w: %w", method, upstream, ErrAPI)
}
// envelopeMessage 是信封 message 进入错误前的唯一处理:压平空白,再交给
// trace.RedactText —— 它既清洗凭据形状,也把长度压到 80 rune(超出的带省略号)。
// 上游常把被拒的输入回显在 message 里,因此这一层不是可选的。
func envelopeMessage(s string) string {
return trace.RedactText(strings.Join(strings.Fields(s), " "))
}
// transportCause 剥掉 net/http 的 *url.Error 外壳:它会把**完整请求地址**拼进错误串
// (query 里的凭据、user:pass@ 都会跟着走)。剥壳保留内层哨兵链
// (context.DeadlineExceeded 等仍可 errors.Is),因此只丢地址、不丢原因。
// 与 internal/channel/mediafetch 的 sanitizeErr 同一口径 —— 那份未导出,且服务于入站
// 取回路径,本包不能反向依赖它。
func transportCause(err error) error {
var uerr *url.Error
if errors.As(err, &uerr) && uerr.Err != nil {
return uerr.Err
}
return err
}