Files
Cyrene_v2/plugins/channels/qq_obv11/http_client.go
T
AskaEth a79fe9684e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): QQ 账号 get_status 主动探活(复用既有 action 通道 / 状态变化才记日志 / AccountStatus 与 WS 连通分开)
2026-10-05 12:10:34 +08:00

186 lines
6.7 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 {
_, err := c.post(ctx, payload)
return err
}
// 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。
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 status=%q msg=%q",
action, env.RetCode, env.Status, env.Msg)
}
return &env, 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
}