package dispatch import ( "context" "time" "github.com/gochat/gochat/internal/channel" applogger "github.com/gochat/gochat/pkg/logger" ) // EventDispatcher wraps the channel.Dispatcher and adds: // - Sync/async split: sync listeners run immediately; async listeners are queued // - Event name routing: listeners subscribe to specific event names // - Singleton pattern: one EventDispatcher per application // // Reference: Chatwoot Dispatcher pattern // - Chatwoot::Dispatcher is a singleton that dispatches events to SyncDispatcher // (immediate) and AsyncDispatcher (Sidekiq job). // - SyncDispatcher listeners: ActionCableListener, AgentBotListener // - AsyncDispatcher listeners: AutomationRuleListener, CampaignListener, // CsatSurveyListener, HookListener, InstallationWebhookListener, // NotificationListener, ParticipationListener, ReportingEventListener, WebhookListener // // In gochat, we wrap the existing channel.Dispatcher to preserve its simple // EventListener interface and add the sync/async + event-name-routing layer. // ListenerMode determines whether a listener runs synchronously or asynchronously. type ListenerMode int const ( // SyncMode means the listener runs in the same goroutine as the dispatcher call. // Use for lightweight, latency-sensitive listeners (e.g. ActionCable, AgentBot). SyncMode ListenerMode = iota // AsyncMode means the listener runs in a background goroutine. // Use for heavier listeners (e.g. AutomationRule, Notification, Webhook). AsyncMode ) // listenerEntry tracks a registered listener together with its mode and // the event names it subscribes to. If eventNames is empty, the listener // receives all events (wildcard). type listenerEntry struct { listener channel.EventListener mode ListenerMode eventNames []string // empty = wildcard (all events) } // EventDispatcher is the enhanced central dispatch hub. // It wraps a channel.Dispatcher and adds sync/async routing and // event-name filtering. type EventDispatcher struct { channelDispatcher *channel.Dispatcher registry *ListenerRegistry entries map[string]listenerEntry // listener name → entry } // NewEventDispatcher creates a new EventDispatcher wrapping the given // channel.Dispatcher. func NewEventDispatcher(cd *channel.Dispatcher) *EventDispatcher { return &EventDispatcher{ channelDispatcher: cd, registry: NewListenerRegistry(), entries: make(map[string]listenerEntry), } } // RegisterSync adds a sync-mode listener for the given event names. // If eventNames is empty, the listener receives all events. func (ed *EventDispatcher) RegisterSync(listener channel.EventListener, eventNames ...string) { ed.entries[listener.Name()] = listenerEntry{ listener: listener, mode: SyncMode, eventNames: eventNames, } ed.registry.Subscribe(listener.Name(), eventNames...) // Also register with the underlying channel.Dispatcher so Dispatch() calls work. ed.channelDispatcher.Register(listener) } // RegisterAsync adds an async-mode listener for the given event names. // If eventNames is empty, the listener receives all events. func (ed *EventDispatcher) RegisterAsync(listener channel.EventListener, eventNames ...string) { ed.entries[listener.Name()] = listenerEntry{ listener: listener, mode: AsyncMode, eventNames: eventNames, } ed.registry.Subscribe(listener.Name(), eventNames...) ed.channelDispatcher.Register(listener) } // Dispatch sends an event to all matching listeners. // Sync listeners run immediately in the calling goroutine. // Async listeners are dispatched via a background goroutine. // Returns the first error encountered from sync listeners (async errors are logged only). func (ed *EventDispatcher) Dispatch(ctx context.Context, event *channel.ChannelEvent) error { subscribers := ed.registry.SubscribersForEvent(string(event.Type)) var firstSyncErr error for _, name := range subscribers { entry, ok := ed.entries[name] if !ok { continue } if entry.mode == SyncMode { if err := entry.listener.OnEvent(ctx, event); err != nil { applogger.L().Errorf("sync listener %s error on event %s: %v", entry.listener.Name(), event.Type, err) if firstSyncErr == nil { firstSyncErr = err } } } else { // Async: run in background goroutine go func(l channel.EventListener, e *channel.ChannelEvent) { asyncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := l.OnEvent(asyncCtx, e); err != nil { applogger.L().Errorf("async listener %s error on event %s: %v", l.Name(), e.Type, err) } }(entry.listener, event) } } return firstSyncErr } // DispatchAsync is a convenience method that always dispatches in async mode, // running all listeners (both sync and async registrations) in background goroutines. // This is useful for fire-and-forget event publishing. func (ed *EventDispatcher) DispatchAsync(ctx context.Context, event *channel.ChannelEvent) { subscribers := ed.registry.SubscribersForEvent(string(event.Type)) for _, name := range subscribers { entry, ok := ed.entries[name] if !ok { continue } go func(l channel.EventListener, e *channel.ChannelEvent) { asyncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := l.OnEvent(asyncCtx, e); err != nil { applogger.L().Errorf("async dispatch: listener %s error on event %s: %v", l.Name(), e.Type, err) } }(entry.listener, event) } } // ChannelDispatcher returns the underlying channel.Dispatcher for direct access // if needed (e.g. for channel-level dispatch without the enhanced routing). func (ed *EventDispatcher) ChannelDispatcher() *channel.Dispatcher { return ed.channelDispatcher } // Registry returns the listener registry for inspection/testing. func (ed *EventDispatcher) Registry() *ListenerRegistry { return ed.registry }