Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user