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 }