package service import ( "context" "encoding/json" "fmt" "strings" "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" "gorm.io/datatypes" ) // CaptainPreferenceService implements business logic for CaptainPreference operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/preferences_controller.rb type CaptainPreferenceService struct { repo *repository.CaptainPreferenceRepo accountRepo *repository.AccountRepo copilotConfigService *CopilotConfigService } func (s *CaptainPreferenceService) SetCopilotConfigService(copilotConfigService *CopilotConfigService) { s.copilotConfigService = copilotConfigService } func NewCaptainPreferenceService(repo *repository.CaptainPreferenceRepo, accountRepo ...*repository.AccountRepo) *CaptainPreferenceService { svc := &CaptainPreferenceService{repo: repo} if len(accountRepo) > 0 { svc.accountRepo = accountRepo[0] } return svc } // --- Request DTOs --- // CreatePreferenceRequest is the DTO for creating a preference. // Reference: Chatwoot enterprise/app/models/captain/preference.rb type CreatePreferenceRequest struct { Tone string `json:"tone" validate:"omitempty,oneof=professional casual friendly formal"` Language string `json:"language" validate:"omitempty,min=1,max=10"` ResponseGuidelines string `json:"response_guidelines" validate:"omitempty,max=2000"` AutoLabelEnabled bool `json:"auto_label_enabled"` AutoFollowUpEnabled bool `json:"auto_follow_up_enabled"` AutoReplyEnabled bool `json:"auto_reply_enabled"` MaxResponseLength int `json:"max_response_length" validate:"omitempty,min=50,max=5000"` CustomPromptSuffix string `json:"custom_prompt_suffix" validate:"omitempty,max=1000"` } // UpdatePreferenceRequest is the DTO for updating a preference. // Reference: Chatwoot enterprise/app/models/captain/preference.rb type UpdatePreferenceRequest struct { Tone string `json:"tone,omitempty" validate:"omitempty,oneof=professional casual friendly formal"` Language string `json:"language,omitempty" validate:"omitempty,min=1,max=10"` ResponseGuidelines *string `json:"response_guidelines,omitempty" validate:"omitempty,max=2000"` AutoLabelEnabled *bool `json:"auto_label_enabled,omitempty"` AutoFollowUpEnabled *bool `json:"auto_follow_up_enabled,omitempty"` AutoReplyEnabled *bool `json:"auto_reply_enabled,omitempty"` MaxResponseLength *int `json:"max_response_length,omitempty" validate:"omitempty,min=50,max=5000"` CustomPromptSuffix *string `json:"custom_prompt_suffix,omitempty" validate:"omitempty,max=1000"` } // UpdateCaptainConfigRequest matches Chatwoot's Captain::PreferencesController params. type UpdateCaptainConfigRequest struct { CaptainModels map[string]string `json:"captain_models"` CaptainFeatures map[string]bool `json:"captain_features"` Behavior *UpdatePreferenceRequest `json:"behavior,omitempty"` } // CaptainConfigPayload is the raw payload returned by Chatwoot preferences show/update. type CaptainConfigPayload struct { Providers map[string]map[string]string `json:"providers"` Models map[string]CaptainModelConfig `json:"models"` Features map[string]CaptainFeatureConfig `json:"features"` ProviderConfig *CopilotProviderConfigPayload `json:"provider_config"` Behavior *CaptainBehaviorPayload `json:"behavior"` } type CaptainBehaviorPayload struct { Tone string `json:"tone"` Language string `json:"language"` MaxResponseLength int `json:"max_response_length"` CustomPromptSuffix string `json:"custom_prompt_suffix"` AutoLabelEnabled bool `json:"auto_label_enabled"` AutoFollowUpEnabled bool `json:"auto_follow_up_enabled"` AutoReplyEnabled bool `json:"auto_reply_enabled"` } type CaptainModelConfig struct { Provider string `json:"provider"` DisplayName string `json:"display_name"` ComingSoon *bool `json:"coming_soon,omitempty"` CreditMultiplier int `json:"credit_multiplier"` } type CaptainFeatureConfig struct { Models []CaptainFeatureModel `json:"models"` Default string `json:"default"` Enabled bool `json:"enabled"` Selected string `json:"selected"` } type CaptainFeatureModel struct { ID string `json:"id"` DisplayName string `json:"display_name"` Provider string `json:"provider"` ComingSoon *bool `json:"coming_soon"` CreditMultiplier int `json:"credit_multiplier"` } var captainFeatures = map[string]struct{}{ "editor": {}, "assistant": {}, "copilot": {}, "label_suggestion": {}, "help_center_search": {}, "audio_transcription": {}, // legacy API compatibility; not exposed by the new page. } var captainFeatureOrder = []string{"editor", "assistant", "copilot", "label_suggestion", "help_center_search"} var captainFeatureEnabledDefaults = map[string]bool{ "editor": true, "assistant": false, "copilot": true, "label_suggestion": false, "help_center_search": false, } // --- Business logic --- // validTones are the allowed tone values for captain preferences. // Reference: Chatwoot enterprise/app/models/captain/preference.rb TONES var validTones = []string{"professional", "casual", "friendly", "formal"} // isValidTone checks if a tone value is valid. func isValidTone(tone string) bool { for _, t := range validTones { if t == tone { return true } } return false } // GetConfig returns Chatwoot's raw Captain preferences payload for an account. func (s *CaptainPreferenceService) GetConfig(ctx context.Context, accountID uint) (*CaptainConfigPayload, error) { account, err := s.findAccount(ctx, accountID) if err != nil { return nil, err } providerConfig, err := s.providerConfigPayload(ctx) if err != nil { return nil, err } behavior, err := s.getOrDefaultPreference(ctx, accountID) if err != nil { return nil, err } return captainConfigPayload(account, providerConfig, behavior), nil } // UpdateConfig merges captain_models/captain_features into account settings and returns the raw payload. func (s *CaptainPreferenceService) UpdateConfig(ctx context.Context, accountID uint, req *UpdateCaptainConfigRequest) (*CaptainConfigPayload, error) { account, err := s.findAccount(ctx, accountID) if err != nil { return nil, err } providerConfig, err := s.providerConfigPayload(ctx) if err != nil { return nil, err } models := jsonMapString(account.CaptainModels) features := jsonMapBool(account.CaptainFeatures) for key, value := range req.CaptainModels { if !isCaptainFeature(key) { continue } if strings.TrimSpace(value) == "" { models[key] = "" continue } if len(strings.TrimSpace(value)) > 255 { return nil, fmt.Errorf("model for %s must be at most 255 characters", key) } models[key] = strings.TrimSpace(value) } for key, value := range req.CaptainFeatures { if !isCaptainFeature(key) { continue } features[key] = value } account.CaptainModels = marshalJSONMap(models) account.CaptainFeatures = marshalJSONMap(features) if err := s.accountRepo.Update(ctx, account); err != nil { return nil, fmt.Errorf("update captain preferences: %w", err) } behavior, err := s.getOrDefaultPreference(ctx, accountID) if err != nil { return nil, err } if req.Behavior != nil { behavior, err = s.updateOrCreatePreference(ctx, accountID, req.Behavior) if err != nil { return nil, err } } return captainConfigPayload(account, providerConfig, behavior), nil } func (s *CaptainPreferenceService) providerConfigPayload(ctx context.Context) (*CopilotProviderConfigPayload, error) { if s.copilotConfigService == nil { return copilotProviderPayload(defaultCopilotProviderSettings(), "", "", nil, nil), nil } payload, err := s.copilotConfigService.Get(ctx) if err != nil { return nil, err } // Account-scoped APIs expose only whether credentials exist. Masked values // are reserved for the SuperAdmin platform endpoint. payload.Chat.APIKey.Masked = "" payload.Embedding.APIKey.Masked = "" return payload, nil } func (s *CaptainPreferenceService) findAccount(ctx context.Context, accountID uint) (*model.Account, error) { if s.accountRepo == nil { return nil, fmt.Errorf("account repository is required for captain preferences") } account, err := s.accountRepo.FindByID(ctx, accountID) if err != nil { return nil, fmt.Errorf("account not found: %w", err) } return account, nil } func captainConfigPayload(account *model.Account, providerConfig *CopilotProviderConfigPayload, preference *model.CaptainPreference) *CaptainConfigPayload { accountModels := jsonMapString(account.CaptainModels) accountFeatures := jsonMapBool(account.CaptainFeatures) features := make(map[string]CaptainFeatureConfig, len(captainFeatureOrder)) modelsByID := map[string]CaptainModelConfig{} providerName := "openai" embeddingProvider := "openai" chatModel := "gpt-4o-mini" embeddingModel := "text-embedding-3-small" if providerConfig != nil { providerName = providerConfig.Chat.Provider embeddingProvider = providerConfig.Embedding.Provider chatModel = providerConfig.Chat.Model embeddingModel = providerConfig.Embedding.Model } for _, key := range captainFeatureOrder { defaultModel := chatModel featureProvider := providerName if key == "help_center_search" { defaultModel = embeddingModel featureProvider = embeddingProvider } selected := accountModels[key] if strings.TrimSpace(selected) == "" { selected = defaultModel } modelIDs := []string{defaultModel} if selected != defaultModel { modelIDs = append([]string{selected}, modelIDs...) } models := make([]CaptainFeatureModel, 0, len(modelIDs)) for _, modelName := range modelIDs { if strings.TrimSpace(modelName) == "" { continue } modelConfig := CaptainModelConfig{Provider: featureProvider, DisplayName: modelName, CreditMultiplier: 1} modelsByID[modelName] = modelConfig models = append(models, CaptainFeatureModel{ ID: modelName, DisplayName: modelName, Provider: featureProvider, CreditMultiplier: 1, }) } enabled, exists := accountFeatures[key] if !exists { enabled = captainFeatureEnabledDefaults[key] } features[key] = CaptainFeatureConfig{ Models: models, Default: defaultModel, Enabled: enabled, Selected: selected, } } providers := map[string]map[string]string{providerName: {"display_name": providerName}} return &CaptainConfigPayload{ Providers: providers, Models: modelsByID, Features: features, ProviderConfig: providerConfig, Behavior: captainBehaviorPayload(preference), } } func isCaptainFeature(key string) bool { _, ok := captainFeatures[key] return ok } func jsonMapString(raw datatypes.JSON) map[string]string { result := map[string]string{} if len(raw) == 0 || string(raw) == "null" { return result } _ = json.Unmarshal(raw, &result) return result } func jsonMapBool(raw datatypes.JSON) map[string]bool { result := map[string]bool{} if len(raw) == 0 || string(raw) == "null" { return result } _ = json.Unmarshal(raw, &result) return result } func marshalJSONMap(value any) datatypes.JSON { raw, _ := json.Marshal(value) return datatypes.JSON(raw) } func defaultCaptainPreference(accountID uint) *model.CaptainPreference { return &model.CaptainPreference{ AccountID: accountID, Tone: "professional", Language: "auto", MaxResponseLength: 500, } } func (s *CaptainPreferenceService) getOrDefaultPreference(ctx context.Context, accountID uint) (*model.CaptainPreference, error) { pref, err := s.repo.GetByAccountID(ctx, accountID) if err == nil { return pref, nil } return defaultCaptainPreference(accountID), nil } func (s *CaptainPreferenceService) updateOrCreatePreference(ctx context.Context, accountID uint, req *UpdatePreferenceRequest) (*model.CaptainPreference, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, fmt.Errorf("validation error: %w", err) } pref, err := s.repo.GetByAccountID(ctx, accountID) create := err != nil if create { pref = defaultCaptainPreference(accountID) } if req.Tone != "" { pref.Tone = req.Tone } if req.Language != "" { pref.Language = req.Language } if req.ResponseGuidelines != nil { pref.ResponseGuidelines = *req.ResponseGuidelines } if req.AutoLabelEnabled != nil { pref.AutoLabelEnabled = *req.AutoLabelEnabled } if req.AutoFollowUpEnabled != nil { pref.AutoFollowUpEnabled = *req.AutoFollowUpEnabled } if req.AutoReplyEnabled != nil { pref.AutoReplyEnabled = *req.AutoReplyEnabled } if req.MaxResponseLength != nil { pref.MaxResponseLength = *req.MaxResponseLength } if req.CustomPromptSuffix != nil { pref.CustomPromptSuffix = *req.CustomPromptSuffix } if create { if err := s.repo.Create(ctx, pref); err != nil { return nil, fmt.Errorf("create preference: %w", err) } } else if err := s.repo.Update(ctx, pref); err != nil { return nil, fmt.Errorf("update preference: %w", err) } return pref, nil } func captainBehaviorPayload(pref *model.CaptainPreference) *CaptainBehaviorPayload { if pref == nil { pref = defaultCaptainPreference(0) } return &CaptainBehaviorPayload{ Tone: pref.Tone, Language: pref.Language, MaxResponseLength: pref.MaxResponseLength, CustomPromptSuffix: pref.CustomPromptSuffix, AutoLabelEnabled: pref.AutoLabelEnabled, AutoFollowUpEnabled: pref.AutoFollowUpEnabled, AutoReplyEnabled: pref.AutoReplyEnabled, } } // Create creates a new preference for an account. func (s *CaptainPreferenceService) Create(ctx context.Context, accountID uint, req *CreatePreferenceRequest) (*model.CaptainPreference, error) { // Validate request if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, fmt.Errorf("validation error: %w", err) } // Check if preference already exists for this account existing, err := s.repo.GetByAccountID(ctx, accountID) if err == nil && existing != nil { return nil, fmt.Errorf("preference already exists for account %d", accountID) } pref := &model.CaptainPreference{ AccountID: accountID, Tone: req.Tone, Language: req.Language, ResponseGuidelines: req.ResponseGuidelines, AutoLabelEnabled: req.AutoLabelEnabled, AutoFollowUpEnabled: req.AutoFollowUpEnabled, AutoReplyEnabled: req.AutoReplyEnabled, MaxResponseLength: req.MaxResponseLength, CustomPromptSuffix: req.CustomPromptSuffix, } // Apply defaults if pref.Tone == "" { pref.Tone = "professional" } if pref.Language == "" { pref.Language = "en" } if pref.MaxResponseLength == 0 { pref.MaxResponseLength = 500 } if err := s.repo.Create(ctx, pref); err != nil { applogger.L().Errorf("Create captain preference: %v", err) return nil, fmt.Errorf("create preference: %w", err) } return pref, nil } // Get retrieves the preference for an account. func (s *CaptainPreferenceService) Get(ctx context.Context, accountID uint) (*model.CaptainPreference, error) { pref, err := s.repo.GetByAccountID(ctx, accountID) if err != nil { return nil, fmt.Errorf("preference not found for account %d: %w", accountID, err) } return pref, nil } // Update updates the preference for an account. func (s *CaptainPreferenceService) Update(ctx context.Context, accountID uint, req *UpdatePreferenceRequest) (*model.CaptainPreference, error) { // Validate request if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, fmt.Errorf("validation error: %w", err) } pref, err := s.repo.GetByAccountID(ctx, accountID) if err != nil { return nil, fmt.Errorf("preference not found for account %d: %w", accountID, err) } // Apply partial updates if req.Tone != "" { pref.Tone = req.Tone } if req.Language != "" { pref.Language = req.Language } if req.ResponseGuidelines != nil { pref.ResponseGuidelines = *req.ResponseGuidelines } if req.AutoLabelEnabled != nil { pref.AutoLabelEnabled = *req.AutoLabelEnabled } if req.AutoFollowUpEnabled != nil { pref.AutoFollowUpEnabled = *req.AutoFollowUpEnabled } if req.AutoReplyEnabled != nil { pref.AutoReplyEnabled = *req.AutoReplyEnabled } if req.MaxResponseLength != nil { pref.MaxResponseLength = *req.MaxResponseLength } if req.CustomPromptSuffix != nil { pref.CustomPromptSuffix = *req.CustomPromptSuffix } if err := s.repo.Update(ctx, pref); err != nil { applogger.L().Errorf("Update captain preference: %v", err) return nil, fmt.Errorf("update preference: %w", err) } return pref, nil } // Delete removes the preference for an account. func (s *CaptainPreferenceService) Delete(ctx context.Context, accountID uint) error { if err := s.repo.Delete(ctx, accountID); err != nil { applogger.L().Errorf("Delete captain preference: %v", err) return fmt.Errorf("delete preference: %w", err) } return nil }