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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user