「接她的话」缺的 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 同样失败)。
183 lines
6.7 KiB
Go
183 lines
6.7 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()
|
||
}
|
||
|
||
// 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
|
||
}
|