Files
Cyrene_v2/plugins/channels/qq_obv11/http_client.go
T
AskaEth b504df01e0 feat(channel): 出站消息 id 采集(可选 seam + qq_obv11 两分支取回)
「接她的话」缺的 A 层:平台给她自己那条出站消息分配的 id,此前在解析的那一刻
就被丢掉(HTTP 只校 retcode、WS 写完不等回声、契约层没有 id 出口)。

- internal/channel/adapter/outbound.go(新):SendReceipt/SendOutcome/SendIDStatus
  + 可选接口 OutboundReceiptAdapter + 调用方入口 SendWithReceipt。**不加进
  PlatformAdapter** —— 四个未接线适配器与全部桩实现一行不改(向后兼容硬指标);
  也不用回调:id 是某一次投递的属性,必须随那次投递返回。
- qq_obv11:HTTP 从响应信封 data.message_id 取;WS 登记 send_msg 的 echo 等待者
  (复用 probe.go 的 action 等待表与 routeActionResponse,不另开通道)。取不到
  一律空 id + 三态可分辨(reported/absent/unreported),绝不编造。
- send_msg echo 键改用单调计数器(旧 UnixNano 在 Windows 时钟粒度下会撞键,
  撞键即拿不到自己的回执)。
- 既有 SendMessage/SendProactive 路径行为不变(wantReceipt=false 不等待)。

验收:go build ./... + go vet ./... + go test ./... 全绿(cmd/cyrene、internal/webui
的 setup 失败是 worktree 缺 dist 的既有环境限制,base 同样失败)。
2026-10-13 20:38:12 +08:00

183 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()
}
// 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 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
}