Files
Cyrene_v2/plugins/channels/qq_obv11/http_client.go
T
AskaEth 9253fef92a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(qq_obv11): OneBot v11 四端齐备(裁决 1.4 / 12.4.1)
- 四端=ws×client / ws×server(原有,补语义注释)+ **http×client**(新增 http_client.go:
  调 OneBot HTTP API、请求-响应、retcode 判成败、token 只走 Authorization 头)+
  **http×server**(新增 http_server.go:监听 listen_addr 收上报、fail-closed 鉴权、10MiB 体上限)
- 配置面:适配器新增 transport / api_base_url / report_path(json tag 沿用既有口径);
  env 兜底 CHANNEL_QQ_OBV11_{TRANSPORT,API_BASE_URL,REPORT_PATH};**默认值=现状**(ws+server)
- 出站分流:session.go 发送路径按 transport 走 WS 写帧或 HTTP POST(不改它则 HTTP 端无法出站)
- 端口豁免边界:http+server 沿用 AGENTS §9 已登记的同一豁免(design/13 §0 原文即含 HTTP server),
  且 listen_addr 为空时报错、**绝不开端口**(与 ws 侧同名校验成对)
- 关键取舍(已上报):OneBot 的 HTTP 是「动作 + 上报」两半,mode 决定**哪一半必需**、
  另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」,四端仍各自可独立选择与测试
- 复核:build/vet 绿 + qq_obv11/cmd 包测试绿 + `-race` 干净;10 条新用例(HTTP 两端全部用本地
  httptest 假 OneBot,未连真实 QQ);变异 3 条(伪造 retcode 成功 / 去掉上报鉴权门 / 默认值翻 http)已还原
- 未接:get_group_list(后续)、无 token 时放行(与既有 ws_server 一致,如需 fail-closed 请裁决)
2026-10-04 13:58:18 +08:00

173 lines
6.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 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"
)
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()
}
// send 把一条已构造好的 action 请求(payload 已含 action/params/echo)POST 给 OneBot
// 并等待结果。它替代 WS 端的「写一帧就返回」:HTTP 有明确响应,成败以 retcode 为准。
func (c *obHTTPClient) send(ctx context.Context, payload []byte) error {
return c.post(ctx, payload)
}
// 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
}
return c.post(ctx, payload)
}
// post 发送一条 action 请求并解析响应。任何失败都返回带上下文(action、retcode)的
// wrapped error;错误信息与日志都不含 token。
func (c *obHTTPClient) post(ctx context.Context, payload []byte) error {
if c == nil {
return 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 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 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 fmt.Errorf("qq-obv11: read http %s response: %w", action, err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("qq-obv11: http %s status %d", action, resp.StatusCode)
}
var env obAPIResponse
if err := json.Unmarshal(body, &env); err != nil {
return fmt.Errorf("qq-obv11: decode http %s response: %w", action, err)
}
if env.RetCode != 0 || (env.Status != "" && !strings.EqualFold(env.Status, "ok")) {
return fmt.Errorf("qq-obv11: http %s failed: retcode=%d status=%q msg=%q",
action, env.RetCode, env.Status, env.Msg)
}
return nil
}
// 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
}