mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-06 18:22:50 +00:00
Squash merge PR #98 in priority order after #97. Existing PR checks were green; branch workflow status will be tracked.
442 lines
15 KiB
Go
442 lines
15 KiB
Go
package channels
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providerresolve"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
|
|
)
|
|
|
|
// reloadStartTimeout bounds how long Reload() will wait for a single channel's
|
|
// Start() to return before abandoning it and continuing with the rest.
|
|
// Sits above Telegram's probeOverallTimeout (60s) so well-behaved channels
|
|
// always get a chance to finish before being given up on.
|
|
// var (not const) so tests can shrink it without waiting a real minute+.
|
|
var reloadStartTimeout = 90 * time.Second
|
|
|
|
// ChannelFactory creates a Channel from DB instance data.
|
|
// name: channel name (registered in Manager, used in session keys).
|
|
// creds: decrypted credentials JSON (token, API keys, etc.).
|
|
// cfg: non-secret config JSONB (dm_policy, dm_stream, group_stream, etc.).
|
|
type ChannelFactory func(name string, creds json.RawMessage, cfg json.RawMessage,
|
|
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (Channel, error)
|
|
|
|
// InstanceLoader loads channel instances from the database and registers them with the Manager.
|
|
// Follows a load-all-at-startup pattern with cache invalidation for reload.
|
|
type InstanceLoader struct {
|
|
store store.ChannelInstanceStore
|
|
agentStore store.AgentStore
|
|
providerReg *providers.Registry
|
|
pendingCompactCfg *config.PendingCompactionConfig
|
|
usageCaps *usagecaps.Service
|
|
factories map[string]ChannelFactory
|
|
manager *Manager
|
|
msgBus *bus.MessageBus
|
|
pairingSvc store.PairingStore
|
|
mu sync.Mutex
|
|
loaded map[string]struct{} // channel names managed by this loader
|
|
}
|
|
|
|
// NewInstanceLoader creates a new InstanceLoader.
|
|
func NewInstanceLoader(
|
|
s store.ChannelInstanceStore,
|
|
agentStore store.AgentStore,
|
|
mgr *Manager,
|
|
msgBus *bus.MessageBus,
|
|
pairingSvc store.PairingStore,
|
|
) *InstanceLoader {
|
|
return &InstanceLoader{
|
|
store: s,
|
|
agentStore: agentStore,
|
|
factories: make(map[string]ChannelFactory),
|
|
manager: mgr,
|
|
msgBus: msgBus,
|
|
pairingSvc: pairingSvc,
|
|
loaded: make(map[string]struct{}),
|
|
}
|
|
}
|
|
|
|
// SetProviderRegistry sets the provider registry for pending message compaction.
|
|
// Must be called before LoadAll/Reload.
|
|
func (l *InstanceLoader) SetProviderRegistry(reg *providers.Registry) {
|
|
l.providerReg = reg
|
|
}
|
|
|
|
// SetPendingCompactionConfig sets the global pending message compaction thresholds.
|
|
// Must be called before LoadAll/Reload.
|
|
func (l *InstanceLoader) SetPendingCompactionConfig(cfg *config.PendingCompactionConfig) {
|
|
l.pendingCompactCfg = cfg
|
|
}
|
|
|
|
func (l *InstanceLoader) SetUsageCapService(s *usagecaps.Service) {
|
|
l.usageCaps = s
|
|
}
|
|
|
|
// RegisterFactory registers a factory for a channel type (e.g., "telegram", "discord").
|
|
func (l *InstanceLoader) RegisterFactory(channelType string, factory ChannelFactory) {
|
|
l.factories[channelType] = factory
|
|
}
|
|
|
|
// LoadAll loads all enabled channel instances from the database, creates channels, and registers them.
|
|
func (l *InstanceLoader) LoadAll(ctx context.Context) error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
instances, err := l.store.ListAllEnabled(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
registered := 0
|
|
for _, inst := range instances {
|
|
// Don't start channels here — StartAll() will start them after all channels are registered.
|
|
if err := l.loadInstance(ctx, inst, false); err != nil {
|
|
slog.Error("failed to load channel instance",
|
|
"name", inst.Name, "type", inst.ChannelType, "error", err)
|
|
continue
|
|
}
|
|
registered++
|
|
}
|
|
|
|
if registered > 0 {
|
|
slog.Info("channel instances loaded from DB", "count", registered)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Reload stops all managed channels, reloads from DB, and starts new ones.
|
|
// Called on cache invalidation events.
|
|
func (l *InstanceLoader) Reload(ctx context.Context) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
// Stop and unregister old channels
|
|
for name := range l.loaded {
|
|
if ch, ok := l.manager.GetChannel(name); ok {
|
|
if err := ch.Stop(ctx); err != nil {
|
|
slog.Warn("failed to stop channel instance on reload", "name", name, "error", err)
|
|
}
|
|
}
|
|
l.manager.UnregisterChannel(name)
|
|
}
|
|
l.loaded = make(map[string]struct{})
|
|
|
|
// Brief pause to let external APIs (e.g., Telegram getUpdates) release polling locks.
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
// Reload from DB (all tenants — server-internal)
|
|
instances, err := l.store.ListAllEnabled(ctx)
|
|
if err != nil {
|
|
slog.Error("failed to reload channel instances", "error", err)
|
|
return
|
|
}
|
|
|
|
registered := 0
|
|
for _, inst := range instances {
|
|
// Reload must start channels immediately (StartAll was called at boot, not again).
|
|
if err := l.loadInstance(ctx, inst, true); err != nil {
|
|
slog.Error("failed to reload channel instance",
|
|
"name", inst.Name, "type", inst.ChannelType, "error", err)
|
|
continue
|
|
}
|
|
registered++
|
|
}
|
|
|
|
slog.Info("channel instances reloaded", "count", registered)
|
|
}
|
|
|
|
// LoadInstanceByID loads or refreshes one enabled channel instance without
|
|
// stopping unrelated channels. Used by create-time cache invalidation and QR
|
|
// auth flows that need the just-created channel to become available quickly.
|
|
func (l *InstanceLoader) LoadInstanceByID(ctx context.Context, id uuid.UUID) error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
inst, err := l.store.Get(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, ok := l.loaded[inst.Name]; ok {
|
|
if _, exists := l.manager.GetChannel(inst.Name); exists {
|
|
return nil
|
|
}
|
|
delete(l.loaded, inst.Name)
|
|
}
|
|
|
|
if !inst.Enabled {
|
|
return nil
|
|
}
|
|
|
|
if err := l.loadInstance(ctx, *inst, true); err != nil {
|
|
return err
|
|
}
|
|
|
|
slog.Info("channel instance loaded by id", "id", id, "name", inst.Name, "type", inst.ChannelType)
|
|
return nil
|
|
}
|
|
|
|
// Stop stops all managed channels.
|
|
func (l *InstanceLoader) Stop(ctx context.Context) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
for name := range l.loaded {
|
|
if ch, ok := l.manager.GetChannel(name); ok {
|
|
if err := ch.Stop(ctx); err != nil {
|
|
slog.Warn("failed to stop channel instance", "name", name, "error", err)
|
|
}
|
|
}
|
|
l.manager.UnregisterChannel(name)
|
|
}
|
|
l.loaded = make(map[string]struct{})
|
|
}
|
|
|
|
// coerceStringBools converts string "true"/"false" values to JSON booleans
|
|
// in a raw config blob. Older UI versions saved select-based bool fields as strings.
|
|
func coerceStringBools(data json.RawMessage) json.RawMessage {
|
|
if len(data) == 0 {
|
|
return data
|
|
}
|
|
var m map[string]any
|
|
if json.Unmarshal(data, &m) != nil {
|
|
return data
|
|
}
|
|
changed := false
|
|
for k, v := range m {
|
|
if s, ok := v.(string); ok {
|
|
switch s {
|
|
case "true":
|
|
m[k] = true
|
|
changed = true
|
|
case "false":
|
|
m[k] = false
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
if !changed {
|
|
return data
|
|
}
|
|
out, _ := json.Marshal(m)
|
|
return out
|
|
}
|
|
|
|
// LoadedNames returns the set of channel names managed by the loader.
|
|
func (l *InstanceLoader) LoadedNames() map[string]struct{} {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
result := make(map[string]struct{}, len(l.loaded))
|
|
maps.Copy(result, l.loaded)
|
|
return result
|
|
}
|
|
|
|
// loadInstance creates and registers a single channel from a DB instance (caller must hold lock).
|
|
// If autoStart is true, the channel is started immediately (used by Reload).
|
|
// If false, the caller is responsible for starting (used by LoadAll, where StartAll handles it).
|
|
func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelInstanceData, autoStart bool) error {
|
|
l.loaded[inst.Name] = struct{}{}
|
|
|
|
factory, ok := l.factories[inst.ChannelType]
|
|
if !ok {
|
|
l.manager.RecordHealth(inst.Name, NewChannelHealthForType(
|
|
inst.ChannelType,
|
|
ChannelHealthStateFailed,
|
|
"Unsupported channel type",
|
|
fmt.Sprintf("No channel factory is registered for %q", inst.ChannelType),
|
|
ChannelFailureKindConfig,
|
|
false,
|
|
))
|
|
slog.Warn("no factory for channel type", "type", inst.ChannelType, "name", inst.Name)
|
|
return nil
|
|
}
|
|
|
|
// Normalize config: convert string "true"/"false" to JSON booleans.
|
|
// Older UI versions saved select-based bool fields as strings.
|
|
cfg := coerceStringBools(inst.Config)
|
|
|
|
ch, err := factory(inst.Name, inst.Credentials, cfg, l.msgBus, l.pairingSvc)
|
|
if err != nil {
|
|
l.manager.RecordFailureForType(inst.Name, inst.ChannelType, "", err)
|
|
return err
|
|
}
|
|
if ch == nil {
|
|
l.manager.RecordHealth(inst.Name, NewChannelHealthForType(
|
|
inst.ChannelType,
|
|
ChannelHealthStateFailed,
|
|
"Missing credentials",
|
|
"Channel instance is enabled but required credentials are incomplete.",
|
|
ChannelFailureKindConfig,
|
|
false,
|
|
))
|
|
slog.Info("channel instance not ready (missing credentials)", "name", inst.Name, "type", inst.ChannelType)
|
|
return nil
|
|
}
|
|
|
|
// Resolve agent_key from UUID — the routing system (Router, session keys) uses agent_key, not UUID.
|
|
// Use the instance's tenant_id to scope the agent lookup.
|
|
instCtx := store.WithTenantID(ctx, inst.TenantID)
|
|
var ag *store.AgentData
|
|
if base, ok := ch.(interface{ SetAgentID(string) }); ok {
|
|
var err error
|
|
ag, err = l.agentStore.GetByID(instCtx, inst.AgentID)
|
|
if err != nil {
|
|
l.manager.RecordFailureForType(inst.Name, inst.ChannelType, "", fmt.Errorf("agent %s not found for channel %s: %w", inst.AgentID, inst.Name, err))
|
|
return fmt.Errorf("agent %s not found for channel %s: %w", inst.AgentID, inst.Name, err)
|
|
}
|
|
base.SetAgentID(ag.AgentKey)
|
|
}
|
|
// Set the platform type on the channel so Manager.ChannelTypeForName can read it.
|
|
if base, ok := ch.(interface{ SetType(string) }); ok {
|
|
base.SetType(inst.ChannelType)
|
|
}
|
|
// Propagate tenant_id from DB instance to channel for tenant-scoped message handling.
|
|
if base, ok := ch.(interface{ SetTenantID(uuid.UUID) }); ok {
|
|
base.SetTenantID(inst.TenantID)
|
|
}
|
|
// Propagate tenant_id to pending history for compaction/sweep DB operations.
|
|
// Factory creates PendingHistory before SetTenantID is called, so tenantID is uuid.Nil at construction.
|
|
if ph, ok := ch.(interface{ SetPendingHistoryTenantID(uuid.UUID) }); ok {
|
|
ph.SetPendingHistoryTenantID(inst.TenantID)
|
|
}
|
|
|
|
// Wire pending message auto-compaction.
|
|
// Priority: config provider/model > agent's provider/model > fallback.
|
|
if pc, ok := ch.(PendingCompactable); ok && l.providerReg != nil {
|
|
var p providers.Provider
|
|
var model string
|
|
|
|
// Try config-level provider/model first.
|
|
tctx := store.WithTenantID(ctx, inst.TenantID)
|
|
if l.pendingCompactCfg != nil && l.pendingCompactCfg.Provider != "" {
|
|
if cp, err := l.providerReg.Get(tctx, l.pendingCompactCfg.Provider); err == nil {
|
|
p = cp
|
|
model = l.pendingCompactCfg.Model
|
|
if model == "" {
|
|
model = cp.DefaultModel()
|
|
}
|
|
}
|
|
}
|
|
// Fallback: agent's provider/model.
|
|
if p == nil && ag != nil && ag.Provider != "" {
|
|
if ap, err := providerresolve.ResolveConfiguredProvider(l.providerReg, ag); err == nil {
|
|
p = ap
|
|
model = ag.Model
|
|
if model == "" {
|
|
model = ap.DefaultModel()
|
|
}
|
|
}
|
|
}
|
|
|
|
if p != nil && model != "" {
|
|
cc := &CompactionConfig{
|
|
Provider: p,
|
|
Model: model,
|
|
UsageCaps: l.usageCaps,
|
|
}
|
|
if l.pendingCompactCfg != nil {
|
|
cc.Threshold = l.pendingCompactCfg.Threshold
|
|
cc.KeepRecent = l.pendingCompactCfg.KeepRecent
|
|
cc.MaxTokens = l.pendingCompactCfg.MaxTokens
|
|
}
|
|
pc.SetPendingCompaction(cc)
|
|
slog.Debug("pending compaction configured", "channel", inst.Name, "provider", p.Name(), "model", model,
|
|
"threshold", cc.Threshold, "keep_recent", cc.KeepRecent, "max_tokens", cc.MaxTokens)
|
|
} else {
|
|
attemptedProvider := ""
|
|
if l.pendingCompactCfg != nil {
|
|
attemptedProvider = l.pendingCompactCfg.Provider
|
|
}
|
|
if attemptedProvider == "" && ag != nil {
|
|
attemptedProvider = ag.Provider
|
|
}
|
|
slog.Warn("pending compaction not configured: provider/model unavailable",
|
|
"channel", inst.Name, "agent_id", inst.AgentID, "attempted_provider", attemptedProvider)
|
|
}
|
|
}
|
|
l.manager.RegisterChannel(inst.Name, ch)
|
|
|
|
// Start the channel if requested (Reload path). LoadAll defers to StartAll.
|
|
// Bound the wait so one hung Start() can't block Reload()'s mutex and wedge
|
|
// every subsequent reload. Important: we pass the caller's ctx (not a
|
|
// timeout-wrapped one) to ch.Start so long-running goroutines the channel
|
|
// derives from it — e.g. Telegram's pollCtx — are not cancelled out from
|
|
// under a successful start.
|
|
if autoStart {
|
|
l.startChannelWithTimeout(ctx, inst, ch)
|
|
}
|
|
|
|
slog.Info("channel instance loaded",
|
|
"name", inst.Name, "type", inst.ChannelType, "agent_id", inst.AgentID)
|
|
return nil
|
|
}
|
|
|
|
// startChannelWithTimeout runs ch.Start(ctx) in a goroutine and waits up to
|
|
// reloadStartTimeout for it to return. On timeout we stop the partially-started
|
|
// channel and record a failure so Reload() can move on to the next instance.
|
|
//
|
|
// ctx is passed through unchanged: channels routinely derive long-lived
|
|
// goroutines (e.g. Telegram long-polling) from this context and must keep
|
|
// running after Start returns. A late-returning Start — i.e. one that ignores
|
|
// the caller ctx entirely — is drained asynchronously so its goroutine doesn't
|
|
// block forever on the send to startErr. If it eventually reports success,
|
|
// we've already called Stop, which is idempotent across channel impls.
|
|
func (l *InstanceLoader) startChannelWithTimeout(ctx context.Context, inst store.ChannelInstanceData, ch Channel) {
|
|
startErr := make(chan error, 1)
|
|
go func() { startErr <- ch.Start(ctx) }()
|
|
|
|
timer := time.NewTimer(reloadStartTimeout)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case err := <-startErr:
|
|
if err != nil {
|
|
l.manager.recordChannelStartFailure(inst.Name, ch, "", err)
|
|
slog.Error("channel instance start failed",
|
|
"name", inst.Name, "type", inst.ChannelType, "error", err)
|
|
return
|
|
}
|
|
l.manager.RecordHealth(inst.Name, snapshotChannelHealth(ch))
|
|
|
|
case <-timer.C:
|
|
// Stop the channel in a bounded window so we don't trade one hang for another.
|
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
if err := ch.Stop(stopCtx); err != nil {
|
|
slog.Warn("failed to stop timed-out channel",
|
|
"name", inst.Name, "type", inst.ChannelType, "error", err)
|
|
}
|
|
stopCancel()
|
|
|
|
timeoutErr := fmt.Errorf("start timed out after %s (type=%s)", reloadStartTimeout, inst.ChannelType)
|
|
l.manager.recordChannelStartFailure(inst.Name, ch, "", timeoutErr)
|
|
slog.Error("channel instance start timed out",
|
|
"name", inst.Name, "type", inst.ChannelType, "timeout", reloadStartTimeout)
|
|
|
|
// Drain the late-returning Start so its goroutine can exit.
|
|
// Logged so operators can spot channels that ignore context cancellation.
|
|
go func() {
|
|
err := <-startErr
|
|
if err != nil {
|
|
slog.Warn("channel instance start returned after timeout",
|
|
"name", inst.Name, "type", inst.ChannelType, "error", err)
|
|
return
|
|
}
|
|
slog.Warn("channel instance start succeeded after timeout; already stopped",
|
|
"name", inst.Name, "type", inst.ChannelType)
|
|
}()
|
|
}
|
|
}
|