Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
334 lines
11 KiB
Go
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
|
|
} |