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 请裁决)
This commit is contained in:
1 parent
0e1f163ef9
commit
9253fef92a
9 files changed
+972
-45
No files matched your search
@@ -0,0 +1,172 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user