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.
216 lines
7.4 KiB
Go
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
|
|
}
|