- 四端=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 请裁决)
173 lines
6.2 KiB
Go
173 lines
6.2 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"
|
||
)
|
||
|
||
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
|
||
}
|