package channelbridge import ( "context" "net/http" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // This file holds the adapter registry and the connect lifecycle of the channel // bridge (design/08 ยง0): adapters are registered through Add, dialed by Start // (each within connectTimeout), torn down by Stop, and skipped by the credential // gate when they carry no usable configuration. // Add registers an adapter by its Name. It is safe to call before Start; adding // after Start has no effect on already-running adapters. func (b *Bridge) Add(a adapter.PlatformAdapter) { if a == nil { return } b.mu.Lock() defer b.mu.Unlock() b.adapters[a.Name()] = a } // WebhookHandlers returns the inbound HTTP handlers of every registered adapter // that exposes an HTTP callback (e.g. *webhook.Adapter), keyed by the endpoint // path from the adapter's config. The gateway mounts these to receive external // webhook callbacks into the runtime. A nil map is returned when no adapter // provides an inbound handler. func (b *Bridge) WebhookHandlers() map[string]http.Handler { b.mu.RLock() defer b.mu.RUnlock() out := make(map[string]http.Handler) for _, a := range b.adapters { wh, ok := a.(webhookAdapter) if !ok { continue } path := wh.WebhookPath() if path == "" { // Adapter is not currently exposing a webhook (e.g. a websocket-mode // KOOK); skipping avoids mounting a handler at a stale or colliding route. continue } h := wh.InboundHandler() if h == nil { continue } out[path] = h } if len(out) == 0 { return nil } return out } // Start connects every configured adapter, installs the inbound handler, and // launches the read loops. Adapters without credentials (or whose Connect fails) // are logged and left disconnected; they never fail startup. Start blocks only // long enough to connect (bounded by connectTimeout per adapter) and returns. It // is idempotent: a second call is a no-op. func (b *Bridge) Start() { b.mu.Lock() if b.closed || b.started { b.mu.Unlock() return } b.ctx, b.cancel = context.WithCancel(context.Background()) b.started = true b.mu.Unlock() for _, a := range b.snapshot() { b.connectAndRun(a) } b.log.Info("channel bridge started", "connected", b.ConnectedCount(), "registered", len(b.snapshot())) } // Stop cancels the read loops, disconnects every adapter and waits for in-flight // message processing to finish. It is idempotent. func (b *Bridge) Stop(ctx context.Context) { b.mu.Lock() if b.closed { b.mu.Unlock() return } b.closed = true cancel := b.cancel b.mu.Unlock() cancel() for name, a := range b.snapshot() { if err := a.Disconnect(ctx); err != nil { b.log.Warn("channel disconnect", "channel", name, "error", err) } } b.wg.Wait() b.log.Info("channel bridge stopped") } // IsConnected reports whether the named adapter is currently connected. func (b *Bridge) IsConnected(name string) bool { b.mu.RLock() defer b.mu.RUnlock() return b.connected[name] } // ConnectedCount reports how many adapters are currently connected. func (b *Bridge) ConnectedCount() int { b.mu.RLock() defer b.mu.RUnlock() n := 0 for _, ok := range b.connected { if ok { n++ } } return n } // snapshot returns a copy of the registered adapters for a lock-free iteration. func (b *Bridge) snapshot() []adapter.PlatformAdapter { b.mu.RLock() defer b.mu.RUnlock() out := make([]adapter.PlatformAdapter, 0, len(b.adapters)) for _, a := range b.adapters { out = append(out, a) } return out } // configured reports whether the adapter carries enough configuration to attempt // a Connect. It inspects the adapter's own Config() snapshot for the credential // each platform needs, so a bare (empty) adapter is skipped rather than dialed. // This is what keeps weixin-ilink from falling into its blocking QR-login flow // when no bot token is present. func (b *Bridge) configured(a adapter.PlatformAdapter) bool { cfg := a.Config() switch basePlatform(a.Name()) { case "telegram", "weixin-ilink", "kook": return cfgStr(cfg, "bot_token") != "" case "qq-obv11": if cfgStr(cfg, "mode") == "client" { return cfgStr(cfg, "remoteURL") != "" } return cfgStr(cfg, "listenAddr") != "" case "webhook": // Inbound-only: no external credential is required to hold the session. return true default: return true } } // cfgStr reads a string field from an adapter config map, returning "" when // absent. func cfgStr(m map[string]any, key string) string { if m == nil { return "" } if s, ok := m[key].(string); ok { return s } return "" } // adapter returns the registered adapter for a platform name, or nil. func (b *Bridge) adapter(platform string) adapter.PlatformAdapter { b.mu.RLock() defer b.mu.RUnlock() return b.adapters[platform] }