Files
gochat/backend/internal/service/integration_hook_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

285 lines
9.7 KiB
Go

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)
}
}