信封路径(非 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 承担分类。 每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
200 lines
7.6 KiB
Go
200 lines
7.6 KiB
Go
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
|
||
}
|