package channel import ( "context" "encoding/json" "fmt" "sync" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/pubsub" ) // --- ChannelStatus enum --- // Reference: P2D §3.2 — tracks per-inbox channel connection state // Chatwoot tracks channel reauthorization status; GoChat extends this to full lifecycle tracking. // ChannelStatus represents the connection status of a channel inbox. type ChannelStatus string const ( ChannelStatusConnected ChannelStatus = "connected" ChannelStatusDisconnected ChannelStatus = "disconnected" ChannelStatusError ChannelStatus = "error" ChannelStatusReconnecting ChannelStatus = "reconnecting" ChannelStatusPending ChannelStatus = "pending" ) // --- StatusTracker: tracks per-inbox connection status in memory --- // Used for monitoring dashboards and health checks. // StatusTracker tracks per-inbox channel connection status in memory. type StatusTracker struct { mu sync.RWMutex status map[uint]ChannelStatus } // NewStatusTracker creates a new StatusTracker. func NewStatusTracker() *StatusTracker { return &StatusTracker{ status: make(map[uint]ChannelStatus), } } // Set updates the connection status for an inbox. func (st *StatusTracker) Set(inboxID uint, status ChannelStatus) { st.mu.Lock() defer st.mu.Unlock() st.status[inboxID] = status } // Get retrieves the connection status for an inbox. // Returns ChannelStatusPending if the inbox has no recorded status. func (st *StatusTracker) Get(inboxID uint) ChannelStatus { st.mu.RLock() defer st.mu.RUnlock() if s, ok := st.status[inboxID]; ok { return s } return ChannelStatusPending } // GetAll returns a snapshot of all tracked inbox statuses. func (st *StatusTracker) GetAll() map[uint]ChannelStatus { st.mu.RLock() defer st.mu.RUnlock() result := make(map[uint]ChannelStatus, len(st.status)) for k, v := range st.status { result[k] = v } return result } // Remove removes tracking for an inbox (e.g. on inbox deletion). func (st *StatusTracker) Remove(inboxID uint) { st.mu.Lock() defer st.mu.Unlock() delete(st.status, inboxID) } // --- LifecycleManager: manages channel connect/disconnect/reconnect lifecycle --- // Reference: P2D §3.2 — Chatwoot's channel setup/teardown + reauthorization // Orchestrates ChannelProvider.OnCreate/OnDestroy + publishes events to EventBus. // LifecycleManager manages the connect/disconnect/reconnect lifecycle of channel providers. // It coordinates with the ChannelRegistry for provider lookup and the EventBus for event publishing. type LifecycleManager struct { registry *ChannelRegistry eventBus *pubsub.EventBus statusTracker *StatusTracker } // NewLifecycleManager creates a new LifecycleManager. func NewLifecycleManager(registry *ChannelRegistry, eventBus *pubsub.EventBus) *LifecycleManager { return &LifecycleManager{ registry: registry, eventBus: eventBus, statusTracker: NewStatusTracker(), } } // ConnectInbox connects a channel inbox to its external provider. // Calls provider.OnCreate() and publishes EventChannelConnected. // Reference: Chatwoot's before_save :setup_*_webhook callbacks func (lm *LifecycleManager) ConnectInbox(ctx context.Context, inbox *model.Inbox) error { if inbox == nil { return fmt.Errorf("inbox is nil") } provider, err := lm.registry.Get(ChannelType(inbox.ChannelType)) if err != nil { return fmt.Errorf("failed to get provider for channel type %s: %w", inbox.ChannelType, err) } // Parse config from Inbox.ChannelConfig JSON string config, parseErr := parseInboxConfig(inbox.ChannelConfig) if parseErr != nil { return fmt.Errorf("failed to parse channel config for inbox %d: %w", inbox.ID, parseErr) } // Call provider's OnCreate to set up external resources (webhooks, subscriptions, etc.) updatedConfig, createErr := provider.OnCreate(ctx, inbox, config) if createErr != nil { lm.statusTracker.Set(inbox.ID, ChannelStatusError) return fmt.Errorf("provider OnCreate failed for inbox %d: %w", inbox.ID, createErr) } // If provider returned updated config, we should persist it. // Note: the actual DB update should be done by the caller (service layer), // but we serialize it here for reference. if updatedConfig != nil { configBytes, marshalErr := json.Marshal(updatedConfig) if marshalErr != nil { applogger.L().Warnf("failed to marshal updated config for inbox %d: %v", inbox.ID, marshalErr) } else { inbox.ChannelConfig = string(configBytes) } } // Update status lm.statusTracker.Set(inbox.ID, ChannelStatusConnected) // Publish connected event if lm.eventBus != nil { topic := pubsub.FormatTopic(pubsub.TopicChannelEvent, inbox.ChannelType) eventPayload, _ := json.Marshal(map[string]interface{}{ "event_type": string(EventChannelConnected), "inbox_id": inbox.ID, "account_id": inbox.AccountID, "channel_type": inbox.ChannelType, }) if publishErr := lm.eventBus.Publish(topic, eventPayload); publishErr != nil { applogger.L().Warnf("failed to publish channel.connected event for inbox %d: %v", inbox.ID, publishErr) } } applogger.L().Infof("inbox %d (channel %s) connected successfully", inbox.ID, inbox.ChannelType) return nil } // DisconnectInbox disconnects a channel inbox from its external provider. // Calls provider.OnDestroy() and publishes EventChannelDisconnected. // Reference: Chatwoot's before_destroy :cleanup_*_webhook callbacks func (lm *LifecycleManager) DisconnectInbox(ctx context.Context, inbox *model.Inbox) error { if inbox == nil { return fmt.Errorf("inbox is nil") } provider, err := lm.registry.Get(ChannelType(inbox.ChannelType)) if err != nil { return fmt.Errorf("failed to get provider for channel type %s: %w", inbox.ChannelType, err) } // Parse config from Inbox.ChannelConfig JSON string config, parseErr := parseInboxConfig(inbox.ChannelConfig) if parseErr != nil { return fmt.Errorf("failed to parse channel config for inbox %d: %w", inbox.ID, parseErr) } // Call provider's OnDestroy to clean up external resources if destroyErr := provider.OnDestroy(ctx, inbox, config); destroyErr != nil { lm.statusTracker.Set(inbox.ID, ChannelStatusError) return fmt.Errorf("provider OnDestroy failed for inbox %d: %w", inbox.ID, destroyErr) } // Update status lm.statusTracker.Set(inbox.ID, ChannelStatusDisconnected) // Publish disconnected event if lm.eventBus != nil { topic := pubsub.FormatTopic(pubsub.TopicChannelEvent, inbox.ChannelType) eventPayload, _ := json.Marshal(map[string]interface{}{ "event_type": string(EventChannelDisconnected), "inbox_id": inbox.ID, "account_id": inbox.AccountID, "channel_type": inbox.ChannelType, }) if publishErr := lm.eventBus.Publish(topic, eventPayload); publishErr != nil { applogger.L().Warnf("failed to publish channel.disconnected event for inbox %d: %v", inbox.ID, publishErr) } } applogger.L().Infof("inbox %d (channel %s) disconnected successfully", inbox.ID, inbox.ChannelType) return nil } // ReconnectInbox reconnects a channel inbox by disconnecting then connecting. // Updates status to reconnecting during the transition. // Reference: Chatwoot's channel reauthorization flow func (lm *LifecycleManager) ReconnectInbox(ctx context.Context, inbox *model.Inbox) error { if inbox == nil { return fmt.Errorf("inbox is nil") } // Mark as reconnecting lm.statusTracker.Set(inbox.ID, ChannelStatusReconnecting) // Disconnect first if disconnectErr := lm.DisconnectInbox(ctx, inbox); disconnectErr != nil { applogger.L().Warnf("disconnect during reconnect for inbox %d failed: %v", inbox.ID, disconnectErr) // Continue with connect even if disconnect fails — some channels don't need clean disconnect } // Re-connect if connectErr := lm.ConnectInbox(ctx, inbox); connectErr != nil { lm.statusTracker.Set(inbox.ID, ChannelStatusError) return fmt.Errorf("reconnect failed for inbox %d: connect error: %w", inbox.ID, connectErr) } applogger.L().Infof("inbox %d (channel %s) reconnected successfully", inbox.ID, inbox.ChannelType) return nil } // RefreshOAuth refreshes OAuth tokens for OAuth-capable channel providers. // Only works for channels that implement OAuthProvider. // Reference: Chatwoot's RefreshOauthTokenService per channel func (lm *LifecycleManager) RefreshOAuth(ctx context.Context, inbox *model.Inbox) error { if inbox == nil { return fmt.Errorf("inbox is nil") } provider, err := lm.registry.Get(ChannelType(inbox.ChannelType)) if err != nil { return fmt.Errorf("failed to get provider for channel type %s: %w", inbox.ChannelType, err) } // Check if provider supports OAuth oauthProvider, ok := provider.(OAuthProvider) if !ok { return fmt.Errorf("channel type %s does not support OAuth refresh", inbox.ChannelType) } // Parse config from Inbox.ChannelConfig JSON string config, parseErr := parseInboxConfig(inbox.ChannelConfig) if parseErr != nil { return fmt.Errorf("failed to parse channel config for inbox %d: %w", inbox.ID, parseErr) } // Refresh the token tokenResult, refreshErr := oauthProvider.RefreshToken(ctx, inbox, config) if refreshErr != nil { lm.statusTracker.Set(inbox.ID, ChannelStatusError) return fmt.Errorf("OAuth token refresh failed for inbox %d: %w", inbox.ID, refreshErr) } // Update config with new token data config["access_token"] = tokenResult.AccessToken if tokenResult.RefreshToken != "" { config["refresh_token"] = tokenResult.RefreshToken } if !tokenResult.ExpiresAt.IsZero() { config["token_expires_at"] = tokenResult.ExpiresAt.Unix() } // Merge extra data from token result if tokenResult.Extra != nil { for k, v := range tokenResult.Extra { config[k] = v } } // Serialize updated config back to inbox configBytes, marshalErr := json.Marshal(config) if marshalErr != nil { return fmt.Errorf("failed to marshal updated config for inbox %d: %w", inbox.ID, marshalErr) } inbox.ChannelConfig = string(configBytes) // Publish reauthorized event if lm.eventBus != nil { topic := pubsub.FormatTopic(pubsub.TopicChannelEvent, inbox.ChannelType) eventPayload, _ := json.Marshal(map[string]interface{}{ "event_type": string(EventChannelReauthorized), "inbox_id": inbox.ID, "account_id": inbox.AccountID, "channel_type": inbox.ChannelType, }) if publishErr := lm.eventBus.Publish(topic, eventPayload); publishErr != nil { applogger.L().Warnf("failed to publish channel.reauthorized event for inbox %d: %v", inbox.ID, publishErr) } } applogger.L().Infof("OAuth token refreshed for inbox %d (channel %s)", inbox.ID, inbox.ChannelType) return nil } // GetChannelStatus returns the current connection status for an inbox. func (lm *LifecycleManager) GetChannelStatus(ctx context.Context, inbox *model.Inbox) ChannelStatus { if inbox == nil { return ChannelStatusPending } return lm.statusTracker.Get(inbox.ID) } // GetStatusTracker returns the underlying StatusTracker for direct access. func (lm *LifecycleManager) GetStatusTracker() *StatusTracker { return lm.statusTracker } // --- Helper: parse Inbox.ChannelConfig JSON string into ChannelConfig map --- // Since Inbox.ChannelConfig is stored as a JSON string (jsonb in DB), we need to parse it. func parseInboxConfig(configJSON string) (ChannelConfig, error) { if configJSON == "" || configJSON == "{}" { return ChannelConfig{}, nil } var config ChannelConfig if err := json.Unmarshal([]byte(configJSON), &config); err != nil { return nil, fmt.Errorf("failed to unmarshal channel config JSON: %w", err) } return config, nil }