Files
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

200 lines
7.6 KiB
Go
Raw Permalink 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 qq_obv11
// 本文件实现 OneBot v11 的 HTTP **client 端**(裁决 1.4 的四个端之一):Cyrene 作为
// HTTP 客户端调用 OneBot 实现(NapCat / go-cqhttp / LLOneBot 等)的 HTTP API,把 action
// 请求 POST 到 `{api_base_url}/{action}`,并*等待*带 echo 的响应,按 retcode/status 判成败。
//
// 对 v1 老代码的批判性取舍(AGENTS §9.5):v1 的发送是「写完就报成功」不等待响应
// (qq/adapter.go:612),出站 token 还拼在 URL query 里(会进日志与代理)。这里改为
// ①请求-响应 + 超时、②出站 token 一律走 `Authorization: Bearer` 头、③错误与日志都不
// 含 token;v1 那种「从 ws:// 地址猜 http:// 基址」的推导也不再沿用——HTTP 基址必须由
// `api_base_url` 显式给出,避免路径剥离猜错。
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
const (
// defaultHTTPTimeout 是单次 OneBot HTTP API 调用的超时上限。HTTP 传输没有连接可
// 观察,一次调用挂死必须自己收敛,不能拖住消息流水线。
defaultHTTPTimeout = 15 * time.Second
// maxHTTPRespBytes 限制单次响应体大小,防御异常对端把进程内存吃满。
maxHTTPRespBytes = 4 << 20
)
// obAPIResponse 是 OneBot v11 HTTP API 的响应信封。
type obAPIResponse struct {
Status string `json:"status"`
RetCode int `json:"retcode"`
Data json.RawMessage `json:"data"`
Msg string `json:"msg"`
Wording string `json:"wording"`
Echo string `json:"echo"`
}
// obActionReq 是通用的 OneBot v11 action 请求体(send_msg 之外的轻量 action 复用,
// 例如探活用的 get_login_info)。
type obActionReq struct {
Action string `json:"action"`
Params any `json:"params"`
Echo string `json:"echo,omitempty"`
}
// obHTTPClient 是 OneBot HTTP API 的出站客户端。它无可变状态,http.Client 自身可并发
// 复用,因此多个发送协程可以同时调用。
type obHTTPClient struct {
baseURL string
token string
http *http.Client
}
// newOBHTTPClient 校验并构造 HTTP 客户端。baseURL 必须是 http/https 且带 host,并且
// **不得内嵌账号密码**(userinfo)——凭据只走 Authorization 头,避免它出现在 URL 与
// 错误信息里。
func newOBHTTPClient(baseURL, token string) (*obHTTPClient, error) {
trimmed := strings.TrimSpace(baseURL)
if trimmed == "" {
return nil, errors.New("qq-obv11: empty api base url")
}
u, err := url.Parse(trimmed)
if err != nil {
return nil, fmt.Errorf("qq-obv11: parse api base url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("qq-obv11: api base url scheme %q (want http/https)", u.Scheme)
}
if u.Host == "" {
return nil, errors.New("qq-obv11: api base url has no host")
}
if u.User != nil {
return nil, errors.New("qq-obv11: api base url must not embed credentials")
}
return &obHTTPClient{
baseURL: strings.TrimRight(trimmed, "/"),
token: token,
http: &http.Client{Timeout: defaultHTTPTimeout},
}, nil
}
// Close 释放连接池中的空闲 keep-alive 连接。HTTP 没有持久会话,重复调用安全。
func (c *obHTTPClient) Close() {
if c == nil {
return
}
c.http.CloseIdleConnections()
}
// probe 用最轻量的标准 action `get_login_info` 探活 OneBot 实现,供 HealthCheck 使用。
// 它不打印 token,也不记录返回的机器人账号信息。
func (c *obHTTPClient) probe(ctx context.Context) error {
payload, err := buildActionReq("get_login_info", map[string]any{}, "")
if err != nil {
return err
}
_, err = c.post(ctx, payload)
return err
}
// call 编码一条通用 action 请求并取回**响应信封**(含 data)。get_status 之类需要读
// 返回值的主动 action 走它——它复用与 send_msg 完全相同的出站客户端与校验路径,绝不
// 新建第二条请求通道。
func (c *obHTTPClient) call(ctx context.Context, action string, params any) (*obAPIResponse, error) {
payload, err := buildActionReq(action, params, "")
if err != nil {
return nil, err
}
return c.post(ctx, payload)
}
// post 发送一条 action 请求并解析响应。任何失败都返回带上下文(action、retcode)的
// wrapped error;错误信息与日志都不含 token。
//
// 它把**响应信封整个交回**调用方:send_msg 的出站消息 id 就在 `data.message_id` 里
// (见 outbound_id.go 的 receiptFromResponse),不再像从前那样只校完 retcode 就把信封
// 丢掉 —— 平台明明回了 id,我们却在解析的那一刻扔掉它,正是本切片要修的缺口。
func (c *obHTTPClient) post(ctx context.Context, payload []byte) (*obAPIResponse, error) {
if c == nil {
return nil, errors.New("qq-obv11: http api client not configured")
}
action := actionOf(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/"+action, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("qq-obv11: build http %s request: %w", action, err)
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
// 出站 token 只走 Authorization 头:不进 URL query、不进日志。
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("qq-obv11: http %s request: %w", action, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxHTTPRespBytes))
if err != nil {
return nil, fmt.Errorf("qq-obv11: read http %s response: %w", action, err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("qq-obv11: http %s status %d", action, resp.StatusCode)
}
var env obAPIResponse
if err := json.Unmarshal(body, &env); err != nil {
return nil, fmt.Errorf("qq-obv11: decode http %s response: %w", action, err)
}
if env.RetCode != 0 || (env.Status != "" && !strings.EqualFold(env.Status, "ok")) {
return nil, fmt.Errorf("qq-obv11: http %s failed: retcode=%d%s",
action, env.RetCode, upstreamStatusSuffix(env.Status))
}
return &env, nil
}
// upstreamStatusSuffix 渲染 OneBot 信封的 status(协议字段,取值 `ok` / `failed`):它
// 过 trace.ErrorToken 的令牌守卫后才出镜,因此上游若在 status 里塞散文,那一段整体
// 消失(与 trace.UpstreamError 对 code/type 的守卫同一口径)。
//
// 🔴 上游的 `msg` / `wording` 是**自由文本面**,一律不进错误也不进日志:NapCat /
// go-cqhttp 会把失败的请求内容回显在 msg 里,而请求里就有她要发的话(M50 口径 ——
// 默认安全,要带必须显式 opt-in,本适配器不开)。排障真正用得到的是 retcode 与
// status,两者恒在。
func upstreamStatusSuffix(status string) string {
if tok := trace.ErrorToken(status); tok != "" {
return fmt.Sprintf(" status=%q", tok)
}
return ""
}
// actionOf 从已编码的 action 请求里取出 action 名,仅用于日志与错误上下文。
func actionOf(payload []byte) string {
var head struct {
Action string `json:"action"`
}
if err := json.Unmarshal(payload, &head); err != nil || head.Action == "" {
return "action"
}
return head.Action
}
// buildActionReq 编码一条通用 OneBot action 请求。
func buildActionReq(action string, params any, echo string) ([]byte, error) {
b, err := json.Marshal(obActionReq{Action: action, Params: params, Echo: echo})
if err != nil {
return nil, fmt.Errorf("qq-obv11: encode %s request: %w", action, err)
}
return b, nil
}