feat(webhooks): align account payloads

This commit is contained in:
2026-06-06 06:26:30 +08:00
parent 1adf1c9c31
commit 02ea135e0c
13 changed files with 495 additions and 94 deletions
@@ -4,10 +4,13 @@ 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.
@@ -16,6 +19,29 @@ 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{
@@ -29,8 +55,23 @@ func (s *WebhookSubscriptionService) ListSubscriptions(ctx context.Context, acco
}
// CreateSubscription creates a new webhook subscription with a generated signing secret.
func (s *WebhookSubscriptionService) CreateSubscription(ctx context.Context, accountID uint, url string, events []string) (*model.WebhookSubscription, error) {
eventsJSON, err := json.Marshal(events)
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)
}
@@ -43,7 +84,9 @@ func (s *WebhookSubscriptionService) CreateSubscription(ctx context.Context, acc
sub := &model.WebhookSubscription{
AccountID: accountID,
URL: url,
InboxID: req.InboxID,
Name: req.Name,
URL: req.URL,
Events: eventsJSON,
Secret: secret,
Active: true,
@@ -79,6 +122,51 @@ func (s *WebhookSubscriptionService) UpdateSubscription(ctx context.Context, id
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)
@@ -101,4 +189,27 @@ func generateWebhookSecret() (string, error) {
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
}
@@ -0,0 +1,74 @@
package service
import (
"context"
"testing"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestWebhookSubscriptionServiceChatwootParity(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WebhookSubscription{}, &model.WebhookDelivery{}))
account := model.Account{Name: "webhook-service-account"}
require.NoError(t, db.Create(&account).Error)
inbox := model.Inbox{AccountID: account.ID, Name: "Support"}
require.NoError(t, db.Create(&inbox).Error)
svc := NewWebhookSubscriptionService(repository.NewWebhookSubscriptionRepo(db))
created, err := svc.CreateWebhook(context.Background(), account.ID, WebhookSubscriptionMutation{
InboxID: &inbox.ID,
Name: "Service hook",
URL: "https://example.com/service-hook",
Subscriptions: []string{"message_created", "conversation_updated"},
})
require.NoError(t, err)
require.NotEmpty(t, created.Secret)
require.Equal(t, "Service hook", created.Name)
require.True(t, created.IsEventSubscribed("message_created"))
updated, err := svc.UpdateWebhook(context.Background(), account.ID, created.ID, WebhookSubscriptionMutation{
Name: "Updated service hook",
URL: "https://example.com/service-hook-updated",
Subscriptions: []string{"contact_created"},
})
require.NoError(t, err)
require.Equal(t, "Updated service hook", updated.Name)
require.True(t, updated.IsEventSubscribed("contact_created"))
require.False(t, updated.IsEventSubscribed("message_created"))
matches, err := repository.NewWebhookSubscriptionRepo(db).ListByAccountAndEvent(context.Background(), account.ID, "contact_created")
require.NoError(t, err)
require.Len(t, matches, 1)
otherAccount := model.Account{Name: "other-webhook-service-account"}
require.NoError(t, db.Create(&otherAccount).Error)
require.Error(t, svc.DeleteWebhook(context.Background(), otherAccount.ID, created.ID))
require.NoError(t, svc.DeleteWebhook(context.Background(), account.ID, created.ID))
}
func TestWebhookSubscriptionServiceValidation(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.WebhookSubscription{}))
svc := NewWebhookSubscriptionService(repository.NewWebhookSubscriptionRepo(db))
_, err = svc.CreateWebhook(context.Background(), 1, WebhookSubscriptionMutation{
URL: "ftp://example.com/hook",
Subscriptions: []string{"message_created"},
})
require.Error(t, err)
_, err = svc.CreateWebhook(context.Background(), 1, WebhookSubscriptionMutation{
URL: "https://example.com/hook",
Subscriptions: []string{"not_allowed"},
})
require.Error(t, err)
}