Files
gochat/backend/internal/service/webhook_subscription_service.go
T
rogee aeddedf2a3 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.
2026-07-07 14:44:12 +08:00

216 lines
7.4 KiB
Go

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
}