// 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. // // 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. package qq_obv11 import ( "context" "errors" "fmt" "log/slog" "net" "net/http" "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 ) // 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". Mode string `json:"mode"` // ListenAddr is the TCP address to bind in server mode (host:port). ListenAddr string `json:"listen_addr"` // RemoteURL is the ws:// endpoint to dial in client mode. RemoteURL string `json:"remote_url"` // AccessToken is the optional OneBot access token for both modes. 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). type Adapter struct { cfg Config log *slog.Logger mu sync.RWMutex conn *wsConn ln net.Listener srv *http.Server 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 } // Compile-time conformance with the channel contract. var _ adapter.PlatformAdapter = (*Adapter)(nil) // NewAdapter builds a qq_obv11 Adapter from cfg, applying safe defaults. func NewAdapter(cfg Config) *Adapter { if cfg.Mode == "" { cfg.Mode = "server" } 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), } } // 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{ "mode": a.cfg.Mode, "listenAddr": a.cfg.ListenAddr, "remoteURL": a.cfg.RemoteURL, "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 according to the configured mode. It is // idempotent. 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 if a.cfg.Mode == "client" { err = a.connectClient(ctx) } else { err = a.connectServer(ctx) } if err != nil { return fmt.Errorf("qq-obv11: connect: %w", err) } a.mu.Lock() a.started = true a.lifeCtx, a.cancel = context.WithCancel(context.Background()) a.mu.Unlock() 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 a.conn = nil a.connected = false a.ln = nil a.srv = nil a.mu.Unlock() if !started && cancel == nil { return nil } if cancel != nil { cancel() } if conn != nil { _ = conn.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. func (a *Adapter) IsConnected() bool { a.mu.RLock() defer a.mu.RUnlock() return a.connected } // HealthCheck verifies the session is usable. func (a *Adapter) HealthCheck(ctx context.Context) error { if !a.IsConnected() { return fmt.Errorf("qq-obv11: health check: %w", adapter.ErrNotConnected) } return nil }