package service import ( "context" "crypto/rand" "encoding/json" "errors" "fmt" "net/url" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "gorm.io/gorm" ) // WebhookSubscriptionService provides business logic for managing webhook subscriptions. // Reference: Chatwoot webhook integration + P2B M8 spec type WebhookSubscriptionService struct { webhookSubRepo *repository.WebhookSubscriptionRepo } var allowedWebhookSubscriptions = map[string]struct{}{ "conversation_status_changed": {}, "conversation_updated": {}, "conversation_created": {}, "contact_created": {}, "contact_updated": {}, "message_created": {}, "message_updated": {}, "webwidget_triggered": {}, "inbox_created": {}, "inbox_updated": {}, "conversation_typing_on": {}, "conversation_typing_off": {}, } // WebhookSubscriptionMutation is the Chatwoot account webhook create/update payload. type WebhookSubscriptionMutation struct { InboxID *uint `json:"inbox_id"` Name string `json:"name"` URL string `json:"url"` Subscriptions []string `json:"subscriptions"` } // NewWebhookSubscriptionService creates a new WebhookSubscription service with required dependencies. func NewWebhookSubscriptionService(webhookSubRepo *repository.WebhookSubscriptionRepo) *WebhookSubscriptionService { return &WebhookSubscriptionService{ webhookSubRepo: webhookSubRepo, } } // ListSubscriptions retrieves all webhook subscriptions for an account. func (s *WebhookSubscriptionService) ListSubscriptions(ctx context.Context, accountID uint) ([]model.WebhookSubscription, error) { return s.webhookSubRepo.ListByAccount(ctx, accountID) } // CreateSubscription creates a new webhook subscription with a generated signing secret. func (s *WebhookSubscriptionService) CreateSubscription(ctx context.Context, accountID uint, webhookURL string, events []string) (*model.WebhookSubscription, error) { req := WebhookSubscriptionMutation{URL: webhookURL, Subscriptions: events} return s.CreateWebhook(ctx, accountID, req) } // CreateWebhook creates a Chatwoot-compatible account webhook. func (s *WebhookSubscriptionService) CreateWebhook(ctx context.Context, accountID uint, req WebhookSubscriptionMutation) (*model.WebhookSubscription, error) { if err := validateWebhookMutation(req, true); err != nil { return nil, err } if existing, err := s.webhookSubRepo.FindByAccountAndURL(ctx, accountID, req.URL); err == nil && existing.ID != 0 { return nil, fmt.Errorf("url has already been taken") } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } eventsJSON, err := json.Marshal(req.Subscriptions) if err != nil { return nil, fmt.Errorf("marshal events: %w", err) } // Generate a random signing secret for HMAC-SHA256 webhook payload signing secret, err := generateWebhookSecret() if err != nil { return nil, fmt.Errorf("generate secret: %w", err) } sub := &model.WebhookSubscription{ AccountID: accountID, InboxID: req.InboxID, Name: req.Name, URL: req.URL, Events: eventsJSON, Secret: secret, Active: true, } if err := s.webhookSubRepo.Create(ctx, sub); err != nil { return nil, err } return sub, nil } // UpdateSubscription updates a webhook subscription's URL, events, or active status. func (s *WebhookSubscriptionService) UpdateSubscription(ctx context.Context, id uint, url string, events []string, active bool) (*model.WebhookSubscription, error) { sub, err := s.webhookSubRepo.FindByID(ctx, id) if err != nil { return nil, fmt.Errorf("find subscription: %w", err) } if url != "" { sub.URL = url } if len(events) > 0 { eventsJSON, err := json.Marshal(events) if err != nil { return nil, fmt.Errorf("marshal events: %w", err) } sub.Events = eventsJSON } sub.Active = active if err := s.webhookSubRepo.Update(ctx, sub); err != nil { return nil, err } return sub, nil } // UpdateWebhook updates a Chatwoot-compatible account webhook scoped to the account. func (s *WebhookSubscriptionService) UpdateWebhook(ctx context.Context, accountID, id uint, req WebhookSubscriptionMutation) (*model.WebhookSubscription, error) { if err := validateWebhookMutation(req, false); err != nil { return nil, err } sub, err := s.webhookSubRepo.FindByAccountAndID(ctx, accountID, id) if err != nil { return nil, fmt.Errorf("find subscription: %w", err) } if req.URL != "" && req.URL != sub.URL { if existing, err := s.webhookSubRepo.FindByAccountAndURL(ctx, accountID, req.URL); err == nil && existing.ID != sub.ID { return nil, fmt.Errorf("url has already been taken") } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } sub.URL = req.URL } sub.Name = req.Name sub.InboxID = req.InboxID eventsJSON, err := json.Marshal(req.Subscriptions) if err != nil { return nil, fmt.Errorf("marshal events: %w", err) } sub.Events = eventsJSON sub.Active = true if err := s.webhookSubRepo.Update(ctx, sub); err != nil { return nil, err } return s.webhookSubRepo.FindByAccountAndID(ctx, accountID, id) } // DeleteWebhook deletes a Chatwoot-compatible account webhook scoped to the account. func (s *WebhookSubscriptionService) DeleteWebhook(ctx context.Context, accountID, id uint) error { if _, err := s.webhookSubRepo.FindByAccountAndID(ctx, accountID, id); err != nil { return fmt.Errorf("find subscription: %w", err) } return s.webhookSubRepo.Delete(ctx, id) } // GetWebhook retrieves a Chatwoot-compatible account webhook scoped to the account. func (s *WebhookSubscriptionService) GetWebhook(ctx context.Context, accountID, id uint) (*model.WebhookSubscription, error) { return s.webhookSubRepo.FindByAccountAndID(ctx, accountID, id) } // DeleteSubscription soft-deletes a webhook subscription. func (s *WebhookSubscriptionService) DeleteSubscription(ctx context.Context, id uint) error { return s.webhookSubRepo.Delete(ctx, id) } // GetSubscription retrieves a single webhook subscription by ID. func (s *WebhookSubscriptionService) GetSubscription(ctx context.Context, id uint) (*model.WebhookSubscription, error) { return s.webhookSubRepo.FindByID(ctx, id) } // ListDeliveries retrieves recent delivery records for a subscription. func (s *WebhookSubscriptionService) ListDeliveries(ctx context.Context, subscriptionID uint, limit int) ([]model.WebhookDelivery, error) { return s.webhookSubRepo.ListDeliveriesBySubscription(ctx, subscriptionID, limit) } // generateWebhookSecret creates a random 32-byte hex-encoded secret for HMAC signing. func generateWebhookSecret() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return fmt.Sprintf("%x", b), nil } func validateWebhookMutation(req WebhookSubscriptionMutation, requireURL bool) error { if requireURL || req.URL != "" { u, err := url.ParseRequestURI(req.URL) if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return fmt.Errorf("url is invalid") } } if len(req.Subscriptions) == 0 { return fmt.Errorf("subscriptions is invalid") } seen := map[string]struct{}{} for _, subscription := range req.Subscriptions { if _, ok := allowedWebhookSubscriptions[subscription]; !ok { return fmt.Errorf("subscriptions is invalid") } if _, ok := seen[subscription]; ok { return fmt.Errorf("subscriptions is invalid") } seen[subscription] = struct{}{} } return nil }