Files
gochat/internal/channel/lifecycle.go
T
2026-06-04 15:44:48 +08:00

334 lines
11 KiB
Go

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
}