feat(qq_obv11): OneBot v11 四端齐备(裁决 1.4 / 12.4.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 四端=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:
AskaEth committed 2026-10-04 13:58:18 +08:00
1 parent 0e1f163ef9
commit 9253fef92a
9 files changed
+972 -45

No files matched your search

+163 -26
View File
@@ -1,14 +1,15 @@
// Package qq_obv11 implements the built-in QQ OneBot v11 channel adapter for
// Cyrene v2. It speaks the OneBot v11 WebSocket protocol (both server mode,
// where the adapter accepts inbound OneBot connections, and client mode, where
// it dials out to a OneBot endpoint), converts native CQ-code / segment payloads
// into the normalized adapter.UnifiedMessage model, and answers via send_msg API
// calls subject to a send rate limit.
// Package qq_obv11 实现内置的 QQ OneBot v11 渠道适配器。它按 `transport`(ws|http)
// × `mode`(client|server) 的**四个端**(裁决 1.4)与 OneBot 实现通信:
//
// The design authority is DevDocs/design/08-渠道接入.md (sections 1, 3, and 4).
// It depends only on internal/channel/adapter, internal/logger, and the standard
// library; the WebSocket frame codec is implemented locally because the module
// pins no third-party websocket dependency.
// - ws + client:Cyrene 主动拨号 OneBot 的 WS 端点,收发共用一个长连接;
// - ws + server:OneBot 反连 Cyrene 的 `listen_addr`,收发共用一个长连接;
// - http + client:Cyrene 作为 HTTP 客户端调用 OneBot 的 HTTP API(出站);
// - http + server:Cyrene 监听 `listen_addr`,接收 OneBot POST 上来的事件上报(入站)。
//
// 原生 CQ 码 / 数组段消息统一归一成 adapter.UnifiedMessage,回复走同一个端并受限频
// 约束。设计依据是 DevDocs/design/08-渠道接入.md §1/§3/§4;本包只依赖
// internal/channel/adapter、internal/logger 与标准库,WS 帧编解码自带(模块不引入
// 第三方 websocket 依赖)。
package qq_obv11
import (
@@ -18,6 +19,7 @@ import (
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"
@@ -34,18 +36,39 @@ const (
defaultMaxProactivePerWindow = 5
// defaultProactiveWindow is the sliding window for the send rate limit.
defaultProactiveWindow = 10 * time.Second
// transportWS / transportHTTP 是 OneBot v11 的两种通信介质(裁决 1.4)。
transportWS = "ws"
transportHTTP = "http"
// modeServer / modeClient 是每种介质下的两个角色:谁向谁发数据(裁决 1.4)。
modeServer = "server"
modeClient = "client"
// defaultReportPath 是 http+server 接收上报的默认路径。
defaultReportPath = "/"
)
// Config configures a qq_obv11 Adapter.
type Config struct {
// Mode selects the WS role: "server" accepts inbound OneBot connections,
// "client" dials the OneBot endpoint. Empty defaults to "server".
// Transport 选择通信介质:"ws"(默认,保持既有部署行为)或 "http"(裁决 1.4)。
// 空值与未知值一律回落 "ws",绝不静默改走 HTTP。
Transport string `json:"transport"`
// Mode 选择该介质下的角色(谁向谁发数据),"server"(默认)或 "client":
// ws —— server 等 OneBot 反连本适配器,client 由本适配器拨号 OneBot;
// http —— server 收 OneBot 的 HTTP 上报,client 调用 OneBot 的 HTTP API。
Mode string `json:"mode"`
// ListenAddr is the TCP address to bind in server mode (host:port).
// **必须由配置显式给出**:OneBot 反连端口是 AGENTS §9 登记的唯一豁免,
// 空值让 Connect 直接失败,绝不默认开监听。
ListenAddr string `json:"listen_addr"`
// RemoteURL is the ws:// endpoint to dial in client mode.
// RemoteURL 是 ws+client 拨号的 ws://(或 wss://)端点。
RemoteURL string `json:"remote_url"`
// AccessToken is the optional OneBot access token for both modes.
// APIBaseURL 是 OneBot HTTP API 基址(如 http://127.0.0.1:3000):http+client
// 必填;http+server 可选,给了才有出站(回消息)能力。必须显式配置——不从
// RemoteURL 推推导(v1「剥掉最后一段路径」的推导是有损的,见 design/08 对照)。
APIBaseURL string `json:"api_base_url"`
// ReportPath 是 http+server 接收上报的路径。空值默认 "/"。
ReportPath string `json:"report_path"`
// AccessToken is the optional OneBot access token for all four ends.
AccessToken string `json:"access_token"`
// SelfID is the bot's own QQ number when known.
SelfID string `json:"self_id"`
@@ -66,7 +89,8 @@ type Config struct {
//
// It is concurrency-safe: one goroutine runs the read loop while multiple
// goroutines may send (writes are serialized on the wsConn and sends are rate
// limited).
// limited). HTTP 传输没有长连接:入站由上报 handler 直接派发,出站走 httpAPI 的
// 请求-响应路径。
type Adapter struct {
cfg Config
log *slog.Logger
@@ -75,6 +99,7 @@ type Adapter struct {
conn *wsConn
ln net.Listener
srv *http.Server
httpAPI *obHTTPClient
started bool
connected bool
cancel context.CancelFunc
@@ -97,11 +122,12 @@ type Adapter struct {
// Compile-time conformance with the channel contract.
var _ adapter.PlatformAdapter = (*Adapter)(nil)
// NewAdapter builds a qq_obv11 Adapter from cfg, applying safe defaults.
// NewAdapter builds a qq_obv11 Adapter from cfg, applying safe defaults. 默认值口径
// (裁决 1.4):transport=ws、mode=server,即**不给出任何新配置时行为与改造前一致**。
func NewAdapter(cfg Config) *Adapter {
if cfg.Mode == "" {
cfg.Mode = "server"
}
cfg.Transport = normalizeTransport(cfg.Transport)
cfg.Mode = normalizeMode(cfg.Mode)
cfg.ReportPath = normalizeReportPath(cfg.ReportPath)
if cfg.MaxProactivePerWindow <= 0 {
cfg.MaxProactivePerWindow = defaultMaxProactivePerWindow
}
@@ -127,9 +153,12 @@ func (a *Adapter) Name() string { return platformName }
// Config returns a snapshot of the adapter configuration as a plain map.
func (a *Adapter) Config() map[string]any {
return map[string]any{
"transport": a.cfg.Transport,
"mode": a.cfg.Mode,
"listenAddr": a.cfg.ListenAddr,
"remoteURL": a.cfg.RemoteURL,
"remoteURL": configRemoteURL(a.cfg),
"apiBaseURL": a.cfg.APIBaseURL,
"reportPath": a.cfg.ReportPath,
"accessToken": a.cfg.AccessToken,
"selfID": a.cfg.SelfID,
"maxProactivePerWindow": a.cfg.MaxProactivePerWindow,
@@ -138,7 +167,7 @@ func (a *Adapter) Config() map[string]any {
}
}
// Connect establishes the session according to the configured mode. It is
// Connect establishes the session for the configured transport × mode. It is
// idempotent.
func (a *Adapter) Connect(ctx context.Context) error {
a.mu.Lock()
@@ -149,21 +178,33 @@ func (a *Adapter) Connect(ctx context.Context) error {
a.mu.Unlock()
var err error
if a.cfg.Mode == "client" {
switch {
case a.cfg.Transport == transportHTTP:
err = a.connectHTTP(ctx)
case a.cfg.Mode == modeClient:
err = a.connectClient(ctx)
} else {
default:
err = a.connectServer(ctx)
}
if err != nil {
return fmt.Errorf("qq-obv11: connect: %w", err)
}
httpTransport := a.cfg.Transport == transportHTTP
a.mu.Lock()
a.started = true
if httpTransport {
// HTTP 没有持久连接可观察:监听已绑定 / 出站客户端已装配即视为可服务,
// 真实可达性交给 HealthCheck 的 get_login_info 探活。
a.connected = true
}
a.lifeCtx, a.cancel = context.WithCancel(context.Background())
a.mu.Unlock()
go a.readLoop(a.lifeCtx)
// WS 两端共用一个长连接读循环;HTTP 的入站由上报 handler 直接进 handlePayload。
if !httpTransport {
go a.readLoop(a.lifeCtx)
}
return nil
}
@@ -178,10 +219,12 @@ func (a *Adapter) Disconnect(ctx context.Context) error {
srv := a.srv
ln := a.ln
conn := a.conn
api := a.httpAPI
a.conn = nil
a.connected = false
a.ln = nil
a.srv = nil
a.httpAPI = nil
a.mu.Unlock()
if !started && cancel == nil {
@@ -193,6 +236,10 @@ func (a *Adapter) Disconnect(ctx context.Context) error {
if conn != nil {
_ = conn.Close()
}
// 出站 HTTP 客户端没有长连接,只需释放连接池里的 keep-alive 连接。
if api != nil {
api.Close()
}
if srv != nil {
shutdownCtx, c := context.WithTimeout(context.Background(), 3*time.Second)
defer c()
@@ -206,17 +253,107 @@ func (a *Adapter) Disconnect(ctx context.Context) error {
return nil
}
// IsConnected reports whether a live OneBot session exists.
// IsConnected reports whether a live OneBot session exists. HTTP 传输没有长连接,
// 它反映的是「适配器已就绪」(监听已绑定 / 出站客户端已装配)。
func (a *Adapter) IsConnected() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.connected
}
// HealthCheck verifies the session is usable.
// HealthCheck verifies the session is usable. HTTP 传输额外做一次 get_login_info
// 探活:没有长连接可观察,只有真实请求才能说明 OneBot 实现可达。
func (a *Adapter) HealthCheck(ctx context.Context) error {
if !a.IsConnected() {
return fmt.Errorf("qq-obv11: health check: %w", adapter.ErrNotConnected)
}
if api := a.currentAPI(); api != nil {
return api.probe(ctx)
}
return nil
}
// currentAPI 返回已装配的 OneBot HTTP API 出站客户端;未装配时(ws 两端,或
// http+server 只收上报、没配 api_base_url)为 nil。
func (a *Adapter) currentAPI() *obHTTPClient {
a.mu.RLock()
defer a.mu.RUnlock()
return a.httpAPI
}
// connectHTTP 装配 HTTP 传输(裁决 1.4)。OneBot v11 的 HTTP 通信天然分两半:动作
// (Cyrene→OneBot,client 端,走 OneBot 的 HTTP API)与上报(OneBot→Cyrene,server
// 端,走本适配器的监听端口)。`mode` 决定哪一半是**必需**的——server 必须有显式
// listen_addr(AGENTS §9 豁免口径:绝不默认开端口),client 必须有 api_base_url;
// 另一半只要对应字段也给齐就一并启用,于是单个适配器即可完成「收上报 + 回消息」闭环。
func (a *Adapter) connectHTTP(ctx context.Context) error {
// 先做纯配置校验并构造出站客户端(无副作用),再绑监听端口:任何一端配置有误都
// 不能留下半开的端口——桥接器对 Connect 失败不会回头调用 Disconnect。
var api *obHTTPClient
if a.cfg.APIBaseURL != "" {
client, err := newOBHTTPClient(a.cfg.APIBaseURL, a.cfg.AccessToken)
if err != nil {
return err
}
api = client
} else if a.cfg.Mode == modeClient {
return errors.New("qq-obv11: http client mode requires apiBaseUrl")
}
if a.cfg.ListenAddr != "" {
if err := a.connectHTTPServer(ctx); err != nil {
return err
}
} else if a.cfg.Mode == modeServer {
return errors.New("qq-obv11: http server mode requires listenAddr")
}
if api != nil {
a.mu.Lock()
a.httpAPI = api
a.mu.Unlock()
}
return nil
}
// configRemoteURL 是 Config() 上报的「远端地址」:channelbridge 的凭据门按
// remoteURL(client 模式)/ listenAddr(server 模式)判断该渠道是否值得连接
// (internal/services/channelbridge/connect.go:143-147)。HTTP client 端的远端就是
// OneBot 的 HTTP API 基址,因此 RemoteURL 为空时回退到 APIBaseURL——否则
// transport=http + mode=client 会被误判成「无凭据」而永不连接。
func configRemoteURL(cfg Config) string {
if cfg.RemoteURL != "" {
return cfg.RemoteURL
}
return cfg.APIBaseURL
}
// normalizeTransport 归一化通信介质:空值保持现状 ws,未知值同样回落 ws,
// 不静默按 http 处理。
func normalizeTransport(raw string) string {
if strings.EqualFold(strings.TrimSpace(raw), transportHTTP) {
return transportHTTP
}
return transportWS
}
// normalizeMode 归一化角色:空值与未知值回落 server(与既有默认一致)。
func normalizeMode(raw string) string {
if strings.EqualFold(strings.TrimSpace(raw), modeClient) {
return modeClient
}
return modeServer
}
// normalizeReportPath 归一化上报路径:空值默认 "/",并保证以 "/" 开头。
func normalizeReportPath(raw string) string {
p := strings.TrimSpace(raw)
switch {
case p == "":
return defaultReportPath
case !strings.HasPrefix(p, "/"):
return "/" + p
default:
return p
}
}