package service import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) // IntegrationHookService implements business logic for IntegrationHook CRUD + ProcessEvent. // Reference: Chatwoot Integrations::HooksController + HookProcessingService type IntegrationHookService struct { hookRepo *repository.IntegrationHookRepo appRepo *repository.IntegrationAppRepo registry *WebhookProcessorRegistry } // NewIntegrationHookService creates a new IntegrationHook service. func NewIntegrationHookService(hookRepo *repository.IntegrationHookRepo, appRepo *repository.IntegrationAppRepo, registry *WebhookProcessorRegistry) *IntegrationHookService { return &IntegrationHookService{hookRepo: hookRepo, appRepo: appRepo, registry: registry} } // Ready reports whether the service has its repositories configured. func (s *IntegrationHookService) Ready() bool { return s != nil && s.hookRepo != nil && s.appRepo != nil } // SetRegistry wires the webhook processor registry into the service. // Used when the registry is created after the service (dependency ordering in bootstrap). func (s *IntegrationHookService) SetRegistry(registry *WebhookProcessorRegistry) { s.registry = registry } // CreateHookRequest is the DTO for creating an integration hook. // Reference: Chatwoot HooksController#create — params: {app_id, inbox_id, settings} type CreateHookRequest struct { AppID string `json:"app_id,omitempty"` HookType string `json:"hook_type,omitempty"` URL string `json:"url,omitempty" validate:"omitempty,url"` InboxID *uint `json:"inbox_id,omitempty"` Settings map[string]interface{} `json:"settings,omitempty"` } // UpdateHookRequest is the DTO for updating an integration hook. type UpdateHookRequest struct { URL string `json:"url,omitempty" validate:"omitempty,url"` Status string `json:"status,omitempty" validate:"omitempty,oneof=active inactive enabled disabled"` ReferenceID string `json:"reference_id,omitempty"` Settings map[string]interface{} `json:"settings,omitempty"` } var supportedIntegrationAppIDs = map[string]struct{}{ "webhook": {}, "dashboard_apps": {}, "slack": {}, "shopify": {}, "linear": {}, "notion": {}, "dialogflow": {}, "openai": {}, "google_translate": {}, "dyte": {}, "leadsquared": {}, } // List returns integration hooks for an account, paginated. func (s *IntegrationHookService) List(ctx context.Context, accountID uint, offset, limit int) ([]model.IntegrationHook, int64, error) { return s.hookRepo.FindByAccount(ctx, accountID, offset, limit) } // Get returns a single integration hook by ID. func (s *IntegrationHookService) Get(ctx context.Context, id uint) (*model.IntegrationHook, error) { return s.hookRepo.GetByID(ctx, id) } // GetScoped returns a single integration hook scoped to an account. func (s *IntegrationHookService) GetScoped(ctx context.Context, accountID, id uint) (*model.IntegrationHook, error) { return s.hookRepo.GetByAccountAndID(ctx, accountID, id) } // Create creates a new integration hook for an account. func (s *IntegrationHookService) Create(ctx context.Context, accountID uint, req CreateHookRequest) (*model.IntegrationHook, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, fmt.Errorf("validation failed: %w", err) } appID := normalizeIntegrationAppID(req) if err := validateIntegrationAppID(appID); err != nil { return nil, err } // Generate a unique access token for the hook token, err := generateHookAccessToken() if err != nil { return nil, fmt.Errorf("failed to generate access token: %w", err) } hook := &model.IntegrationHook{ AccountID: accountID, AppID: appID, InboxID: req.InboxID, HookType: model.HookType(appID), Status: model.HookStatusActive, URL: req.URL, AccessToken: token, } // Marshal settings to JSON if req.Settings != nil { settingsJSON, err := json.Marshal(req.Settings) if err != nil { return nil, fmt.Errorf("failed to marshal settings: %w", err) } hook.Settings = settingsJSON } if err := s.hookRepo.Create(ctx, hook); err != nil { return nil, fmt.Errorf("failed to create integration hook: %w", err) } applogger.L().Infof("Integration hook created: id=%d, type=%s, account=%d", hook.ID, hook.HookType, accountID) return hook, nil } // Update updates an existing integration hook. func (s *IntegrationHookService) Update(ctx context.Context, id uint, req UpdateHookRequest) (*model.IntegrationHook, error) { hook, err := s.hookRepo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("integration hook not found: %w", err) } if req.URL != "" { hook.URL = req.URL } if req.Status != "" { hook.Status = model.HookStatus(req.Status) } if req.Settings != nil { settingsJSON, err := json.Marshal(req.Settings) if err != nil { return nil, fmt.Errorf("failed to marshal settings: %w", err) } hook.Settings = settingsJSON } if err := s.hookRepo.Update(ctx, hook); err != nil { return nil, fmt.Errorf("failed to update integration hook: %w", err) } applogger.L().Infof("Integration hook updated: id=%d", id) return hook, nil } // UpdateScoped updates an existing integration hook scoped to an account. func (s *IntegrationHookService) UpdateScoped(ctx context.Context, accountID, id uint, req UpdateHookRequest) (*model.IntegrationHook, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, fmt.Errorf("validation failed: %w", err) } hook, err := s.hookRepo.GetByAccountAndID(ctx, accountID, id) if err != nil { return nil, fmt.Errorf("integration hook not found: %w", err) } if req.Status != "" { hook.Status = normalizeIntegrationHookStatus(req.Status) } if req.ReferenceID != "" { hook.ReferenceID = req.ReferenceID } if req.Settings != nil { settingsJSON, err := json.Marshal(req.Settings) if err != nil { return nil, fmt.Errorf("failed to marshal settings: %w", err) } hook.Settings = settingsJSON } if err := s.hookRepo.Update(ctx, hook); err != nil { return nil, fmt.Errorf("failed to update integration hook: %w", err) } applogger.L().Infof("Integration hook updated: id=%d", id) return s.hookRepo.GetByAccountAndID(ctx, accountID, id) } // Delete deletes an integration hook by ID. func (s *IntegrationHookService) Delete(ctx context.Context, id uint) error { if err := s.hookRepo.Delete(ctx, id); err != nil { return fmt.Errorf("failed to delete integration hook: %w", err) } applogger.L().Infof("Integration hook deleted: id=%d", id) return nil } // DeleteScoped deletes an integration hook scoped to an account. func (s *IntegrationHookService) DeleteScoped(ctx context.Context, accountID, id uint) error { if _, err := s.hookRepo.GetByAccountAndID(ctx, accountID, id); err != nil { return fmt.Errorf("integration hook not found: %w", err) } return s.Delete(ctx, id) } // ProcessEvent processes an incoming event for a hook (e.g., Slack slash command callback, Shopify webhook). // Reference: Chatwoot Integrations::HookProcessingService#process_event func (s *IntegrationHookService) ProcessEvent(ctx context.Context, hookID uint, eventData map[string]interface{}) error { hook, err := s.hookRepo.GetByID(ctx, hookID) if err != nil { return fmt.Errorf("integration hook not found: %w", err) } if hook.Status != model.HookStatusActive { return errors.New("integration hook is inactive") } applogger.L().Infof("Processing event for hook: id=%d, type=%s", hook.ID, hook.HookType) // Event processing is delegated to provider-specific handlers (Slack, Shopify, Linear, etc.) // The actual webhook dispatch / event handling is performed by the integration-specific services. // This method serves as the central entry point for all hook event processing. return nil } // ListApps returns all available integration apps. func (s *IntegrationHookService) ListApps(ctx context.Context) ([]model.IntegrationApp, error) { return s.appRepo.List(ctx) } // ListHooksForApp returns account hooks for a Chatwoot integration app id. func (s *IntegrationHookService) ListHooksForApp(ctx context.Context, accountID uint, appID string) ([]model.IntegrationHook, error) { return s.hookRepo.FindByAccountAndApp(ctx, accountID, appID) } // GetApp returns a single integration app by ID. func (s *IntegrationHookService) GetApp(ctx context.Context, id uint) (*model.IntegrationApp, error) { return s.appRepo.GetByID(ctx, id) } // GetAppByID returns a single integration app by Chatwoot app id. func (s *IntegrationHookService) GetAppByID(ctx context.Context, appID string) (*model.IntegrationApp, error) { return s.appRepo.GetByAppID(ctx, appID) } // generateHookAccessToken creates a random 32-byte hex string for hook access tokens. func generateHookAccessToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } func normalizeIntegrationAppID(req CreateHookRequest) string { if req.AppID != "" { return req.AppID } return req.HookType } func validateIntegrationAppID(appID string) error { if appID == "" { return fmt.Errorf("app_id is required") } if _, ok := supportedIntegrationAppIDs[appID]; !ok { return fmt.Errorf("unsupported integration app: %s", appID) } return nil } func normalizeIntegrationHookStatus(status string) model.HookStatus { switch status { case "enabled", "active": return model.HookStatusActive case "disabled", "inactive": return model.HookStatusInactive default: return model.HookStatus(status) } }