Files
Cyrene_v2/plugins/channels/qq_obv11/adapter.go
T
AskaEth 8c437866e7
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(qq-obv11): client 模式首连失败不再致命(复用既有重连循环 + 指数退避)
痛点:mode=client 时首次拨号失败会让 Connect 直接返回 error ⇒ 渠道起不来;
而「连上后再断」本来就有重连。⇒ 把 readLoop 的入口条件放宽为「conn==nil 也算」,
**不新造第二套循环**。
新增 reconnect.go:退避 2s → ×2 → 上限 60s、抖动 ±25%(具名常量),失败计数跨
Connect 与 readLoop 共享。
ws_client:新增 connectFirstClient —— 可重试失败只记 WARN 并返回 nil;
配置缺失(remote_url 空)仍致命(闸门口径不变)。
connected 语义不许撒谎:始终由真实连接决定;HealthCheck 未连上返回 ErrNotConnected;
仅 http 传输保留「已装配即可服务」旧口径(逐字未改)。
ctx 感知等待(sleepCtx)+ 等待后复查 ctx ⇒ Disconnect 能立即中止,不泄漏 goroutine。
server 模式与 http 三端行为逐字不变(既有用例零改动全绿 + TestServerModeUnaffectedByClientRetry)。
测试:client_retry_test.go(428) + reconnect_test.go(83),用可注入 dialer + gate 通道造确定性时序。
变异 5 条(首连 fail-fast / 退避无上限 / 取消失效 / 未连上就报已连 / 抖动未接线)。
诚实边界:60s 上限对聊天渠道可能偏慢(改 30s 是一行常量);仓库内仍存第二份指数退避
(wsutil.Backoff 无抖动能力,收敛需改 internal/**,本切片禁碰)。
2026-10-05 10:12:23 +08:00

366 lines
13 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 实现内置的 QQ OneBot v11 渠道适配器。它按 `transport`(ws|http)
// × `mode`(client|server) 的**四个端**(裁决 1.4)与 OneBot 实现通信:
//
// - 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 (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// defaultMaxMessageLength bounds a single outgoing QQ message length.
defaultMaxMessageLength = 4000
// defaultRecommendBurstMax is the safe burst size for one reply.
defaultRecommendBurstMax = 4
// defaultMaxProactivePerWindow caps proactive sends within the window.
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 {
// 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 是 ws+client 拨号的 ws://(或 wss://)端点。
RemoteURL string `json:"remote_url"`
// 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"`
// MaxProactivePerWindow caps sends within ProactiveWindow. Zero uses the
// default.
MaxProactivePerWindow int
// ProactiveWindow is the rate-limit sliding window. Zero uses the default.
ProactiveWindow time.Duration
// SendInterval is a minimum delay between consecutive sends, when set.
SendInterval time.Duration
// DialContext dials the WS endpoint in client mode. Nil uses net.Dialer.
DialContext DialContextFunc
// Logger is the base logger. Nil builds one from internal/logger.
Logger *slog.Logger
}
// Adapter is the OneBot v11 implementation of adapter.PlatformAdapter.
//
// 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). HTTP 传输没有长连接:入站由上报 handler 直接派发,出站走 httpAPI 的
// 请求-响应路径。
type Adapter struct {
cfg Config
log *slog.Logger
mu sync.RWMutex
conn *wsConn
ln net.Listener
srv *http.Server
httpAPI *obHTTPClient
started bool
connected bool
cancel context.CancelFunc
lifeCtx context.Context
newConn chan struct{}
handlerMu sync.RWMutex
handler adapter.MessageHandler
emitMu sync.Mutex
emit func(adapter.UnifiedMessage)
limiter *rateLimiter
groupMu sync.RWMutex
groups map[string]string
lastSend time.Time
// reconnect / policy 只服务 ws+client 的首连与重连退避(见 reconnect.go)。
reconnect reconnectState
policy reconnectPolicy
}
// Compile-time conformance with the channel contract.
var _ adapter.PlatformAdapter = (*Adapter)(nil)
// NewAdapter builds a qq_obv11 Adapter from cfg, applying safe defaults. 默认值口径
// (裁决 1.4):transport=ws、mode=server,即**不给出任何新配置时行为与改造前一致**。
func NewAdapter(cfg Config) *Adapter {
cfg.Transport = normalizeTransport(cfg.Transport)
cfg.Mode = normalizeMode(cfg.Mode)
cfg.ReportPath = normalizeReportPath(cfg.ReportPath)
if cfg.MaxProactivePerWindow <= 0 {
cfg.MaxProactivePerWindow = defaultMaxProactivePerWindow
}
if cfg.ProactiveWindow <= 0 {
cfg.ProactiveWindow = defaultProactiveWindow
}
base := cfg.Logger
if base == nil {
base = logger.New(logger.Options{})
}
return &Adapter{
cfg: cfg,
log: logger.WithSource(base, "plugins/channels/qq_obv11"),
groups: map[string]string{},
newConn: make(chan struct{}, 1),
limiter: newRateLimiter(cfg.MaxProactivePerWindow, cfg.ProactiveWindow),
policy: defaultReconnectPolicy(),
}
}
// Name returns the platform identifier "qq-obv11".
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": configRemoteURL(a.cfg),
"apiBaseURL": a.cfg.APIBaseURL,
"reportPath": a.cfg.ReportPath,
"accessToken": a.cfg.AccessToken,
"selfID": a.cfg.SelfID,
"maxProactivePerWindow": a.cfg.MaxProactivePerWindow,
"proactiveWindowSeconds": a.cfg.ProactiveWindow.Seconds(),
"sendIntervalSeconds": a.cfg.SendInterval.Seconds(),
}
}
// Connect establishes the session for the configured transport × mode. It is
// idempotent. ws+client 的首连失败**不致命**(对面可能比 Cyrene 后启动):
// Connect 照常返回 nil,重试交给 readLoop 的同一条重连路径。
func (a *Adapter) Connect(ctx context.Context) error {
a.mu.Lock()
if a.started {
a.mu.Unlock()
return nil
}
a.mu.Unlock()
var err error
switch {
case a.cfg.Transport == transportHTTP:
err = a.connectHTTP(ctx)
case a.cfg.Mode == modeClient:
err = a.connectFirstClient(ctx)
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()
// WS 两端共用一个长连接读循环;HTTP 的入站由上报 handler 直接进 handlePayload。
if !httpTransport {
go a.readLoop(a.lifeCtx)
}
return nil
}
// Disconnect tears down the session. It is idempotent.
func (a *Adapter) Disconnect(ctx context.Context) error {
a.mu.Lock()
started := a.started
a.started = false
cancel := a.cancel
a.cancel = nil
a.lifeCtx = nil
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 {
return nil
}
if cancel != nil {
cancel()
}
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()
if serr := srv.Shutdown(shutdownCtx); serr != nil && !errors.Is(serr, context.DeadlineExceeded) {
return fmt.Errorf("qq-obv11: server shutdown: %w", serr)
}
}
if ln != nil {
_ = ln.Close()
}
return nil
}
// 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. 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
}
}