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 } 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"` } // 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"` } 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 captainProviders = map[string]map[string]string{ "openai": {"display_name": "OpenAI"}, "anthropic": {"display_name": "Anthropic"}, "gemini": {"display_name": "Gemini"}, } var captainModels = map[string]CaptainModelConfig{ "gpt-4.1": {Provider: "openai", DisplayName: "GPT-4.1", CreditMultiplier: 3}, "gpt-4.1-mini": {Provider: "openai", DisplayName: "GPT-4.1 Mini", CreditMultiplier: 1}, "gpt-4.1-nano": {Provider: "openai", DisplayName: "GPT-4.1 Nano", CreditMultiplier: 1}, "gpt-5.1": {Provider: "openai", DisplayName: "GPT-5.1", CreditMultiplier: 2}, "gpt-5-mini": {Provider: "openai", DisplayName: "GPT-5 Mini", CreditMultiplier: 1}, "gpt-5-nano": {Provider: "openai", DisplayName: "GPT-5 Nano", CreditMultiplier: 1}, "gpt-5.2": {Provider: "openai", DisplayName: "GPT-5.2", CreditMultiplier: 3}, "claude-haiku-4.5": {Provider: "anthropic", DisplayName: "Claude Haiku 4.5", ComingSoon: boolPtr(true), CreditMultiplier: 2}, "claude-sonnet-4.5": {Provider: "anthropic", DisplayName: "Claude Sonnet 4.5", ComingSoon: boolPtr(true), CreditMultiplier: 3}, "gemini-3-flash": {Provider: "gemini", DisplayName: "Gemini 3 Flash", ComingSoon: boolPtr(true), CreditMultiplier: 1}, "gemini-3-pro": {Provider: "gemini", DisplayName: "Gemini 3 Pro", ComingSoon: boolPtr(true), CreditMultiplier: 3}, "whisper-1": {Provider: "openai", DisplayName: "Whisper", CreditMultiplier: 1}, "text-embedding-3-small": {Provider: "openai", DisplayName: "Text Embedding 3 Small", CreditMultiplier: 1}, } var captainFeatureModels = map[string][]string{ "editor": {"gpt-4.1-mini", "gpt-4.1-nano", "gpt-5-mini", "gpt-4.1", "gpt-5.1", "gpt-5.2", "claude-haiku-4.5", "gemini-3-flash", "gemini-3-pro"}, "assistant": {"gpt-5-mini", "gpt-4.1", "gpt-5.1", "gpt-5.2", "claude-haiku-4.5", "claude-sonnet-4.5", "gemini-3-flash", "gemini-3-pro"}, "copilot": {"gpt-5-mini", "gpt-4.1", "gpt-5.1", "gpt-5.2", "claude-haiku-4.5", "claude-sonnet-4.5", "gemini-3-flash", "gemini-3-pro"}, "label_suggestion": {"gpt-4.1-nano", "gpt-4.1-mini", "gpt-5-mini", "gemini-3-flash", "claude-haiku-4.5"}, "audio_transcription": {"whisper-1"}, "help_center_search": {"text-embedding-3-small"}, } var captainFeatureDefaults = map[string]string{ "editor": "gpt-4.1-mini", "assistant": "gpt-5.1", "copilot": "gpt-5.1", "label_suggestion": "gpt-4.1-nano", "audio_transcription": "whisper-1", "help_center_search": "text-embedding-3-small", } var captainFeatureOrder = []string{"editor", "assistant", "copilot", "label_suggestion", "audio_transcription", "help_center_search"} // --- 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 } return captainConfigPayload(account), 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 } 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 !validCaptainModelFor(key, value) { return nil, fmt.Errorf("'%s' is not a valid model for %s. Allowed: %s", value, key, strings.Join(captainFeatureModels[key], ", ")) } models[key] = 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) } return captainConfigPayload(account), 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) *CaptainConfigPayload { accountModels := jsonMapString(account.CaptainModels) accountFeatures := jsonMapBool(account.CaptainFeatures) features := make(map[string]CaptainFeatureConfig, len(captainFeatureOrder)) for _, key := range captainFeatureOrder { models := make([]CaptainFeatureModel, 0, len(captainFeatureModels[key])) for _, modelName := range captainFeatureModels[key] { modelConfig := captainModels[modelName] models = append(models, CaptainFeatureModel{ ID: modelName, DisplayName: modelConfig.DisplayName, Provider: modelConfig.Provider, ComingSoon: modelConfig.ComingSoon, CreditMultiplier: modelConfig.CreditMultiplier, }) } selected := accountModels[key] if !validCaptainModelFor(key, selected) { selected = captainFeatureDefaults[key] } features[key] = CaptainFeatureConfig{ Models: models, Default: captainFeatureDefaults[key], Enabled: accountFeatures[key], Selected: selected, } } return &CaptainConfigPayload{Providers: captainProviders, Models: captainModels, Features: features} } func isCaptainFeature(key string) bool { _, ok := captainFeatureModels[key] return ok } func validCaptainModelFor(feature, modelName string) bool { if modelName == "" { return false } for _, allowed := range captainFeatureModels[feature] { if allowed == modelName { return true } } return false } 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 boolPtr(value bool) *bool { return &value } // 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 != "" { 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 != "" { 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 }