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" ) // AgentBotService implements business logic for AgentBot CRUD + ResetToken + ResetSecret. // Reference: Chatwoot app/controllers/api/v1/accounts/agent_bots_controller.rb type AgentBotService struct { repo *repository.AgentBotRepo } // NewAgentBotService creates a new AgentBot service. func NewAgentBotService(repo *repository.AgentBotRepo) *AgentBotService { return &AgentBotService{repo: repo} } // CreateAgentBotRequest is the DTO for creating an agent bot. type CreateAgentBotRequest struct { Name string `json:"name" form:"name" validate:"required,min=2"` Description string `json:"description,omitempty" form:"description"` AvatarURL string `json:"avatar_url,omitempty" form:"avatar_url"` OutgoingURL string `json:"outgoing_url,omitempty" form:"outgoing_url"` BotType string `json:"bot_type,omitempty" form:"bot_type" validate:"omitempty,oneof=webhook default custom"` AccountID *uint `json:"account_id,omitempty"` // nil = platform-level (global) bot Config json.RawMessage `json:"config,omitempty"` BotConfig json.RawMessage `json:"bot_config,omitempty" form:"bot_config"` } // UpdateAgentBotRequest is the DTO for updating an agent bot. type UpdateAgentBotRequest struct { Name *string `json:"name,omitempty" form:"name" validate:"omitempty,min=2"` Description *string `json:"description,omitempty" form:"description"` AvatarURL *string `json:"avatar_url,omitempty" form:"avatar_url"` OutgoingURL *string `json:"outgoing_url,omitempty" form:"outgoing_url"` BotType *string `json:"bot_type,omitempty" form:"bot_type" validate:"omitempty,oneof=webhook default custom"` Config json.RawMessage `json:"config,omitempty"` BotConfig json.RawMessage `json:"bot_config,omitempty" form:"bot_config"` } // generateBotAccessToken creates a cryptographically secure random token for bot access. func generateBotAccessToken() (string, error) { b := make([]byte, 32) // 256-bit token if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // generateBotSecret creates a cryptographically secure random secret for webhook signing. func generateBotSecret() (string, error) { b := make([]byte, 24) // 192-bit secret if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // Create creates a new AgentBot with auto-generated AccessToken and Secret. // Reference: Chatwoot AccessTokenable concern — auto-create AccessToken on bot creation func (s *AgentBotService) Create(ctx context.Context, req CreateAgentBotRequest) (*model.AgentBot, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } if req.Config == nil && req.BotConfig != nil { req.Config = req.BotConfig } token, err := generateBotAccessToken() if err != nil { return nil, fmt.Errorf("failed to generate access token: %w", err) } secret, err := generateBotSecret() if err != nil { return nil, fmt.Errorf("failed to generate secret: %w", err) } bot := &model.AgentBot{ Name: req.Name, Description: req.Description, AvatarURL: req.AvatarURL, OutgoingURL: req.OutgoingURL, BotType: req.BotType, AccountID: req.AccountID, Config: req.Config, AccessToken: token, Secret: secret, } if bot.BotType == "" { bot.BotType = "webhook" } if err := s.repo.Create(ctx, bot); err != nil { applogger.L().Errorf("Create agent bot: %v", err) return nil, err } applogger.L().Infof("Created agent bot %d (%s) for account %v", bot.ID, bot.Name, bot.AccountID) return bot, nil } // Get retrieves an agent bot by ID. func (s *AgentBotService) Get(ctx context.Context, id uint) (*model.AgentBot, error) { bot, err := s.repo.GetByID(ctx, id) if err != nil { applogger.L().Errorf("Get agent bot %d: %v", id, err) return nil, err } return bot, nil } // GetAccessible retrieves a system or account-owned bot visible to the account. func (s *AgentBotService) GetAccessible(ctx context.Context, accountID uint, id uint) (*model.AgentBot, error) { bot, err := s.repo.FindAccessibleByID(ctx, accountID, id) if err != nil { applogger.L().Errorf("Get accessible agent bot %d account %d: %v", id, accountID, err) return nil, fmt.Errorf("agent bot not found: %w", err) } return bot, nil } // ListAccessible retrieves agent bots accessible to an account (global + account-scoped). // Reference: Chatwoot AgentBot.scope :accessible_to(account) func (s *AgentBotService) ListAccessible(ctx context.Context, accountID uint, offset, limit int) ([]model.AgentBot, int64, error) { bots, total, err := s.repo.FindAccessiblePaginated(ctx, accountID, offset, limit) if err != nil { applogger.L().Errorf("ListAccessible agent bots account %d: %v", accountID, err) return nil, 0, err } return bots, total, nil } // ListAccessibleAll retrieves all system and account-owned bots visible to the // account. Chatwoot AgentBotsController#index does not paginate this list. func (s *AgentBotService) ListAccessibleAll(ctx context.Context, accountID uint) ([]model.AgentBot, error) { bots, err := s.repo.FindAccessible(ctx, accountID) if err != nil { applogger.L().Errorf("ListAccessibleAll agent bots account %d: %v", accountID, err) return nil, err } return bots, nil } // List retrieves all agent bots for an account (account-scoped only). func (s *AgentBotService) List(ctx context.Context, accountID uint) ([]model.AgentBot, error) { bots, err := s.repo.FindByAccountID(ctx, accountID) if err != nil { applogger.L().Errorf("List agent bots account %d: %v", accountID, err) return nil, err } return bots, nil } // Update modifies an existing AgentBot. func (s *AgentBotService) Update(ctx context.Context, id uint, req UpdateAgentBotRequest) (*model.AgentBot, error) { return s.update(ctx, nil, id, req) } // UpdateByAccount modifies an account-owned agent bot. func (s *AgentBotService) UpdateByAccount(ctx context.Context, accountID uint, id uint, req UpdateAgentBotRequest) (*model.AgentBot, error) { return s.update(ctx, &accountID, id, req) } func (s *AgentBotService) update(ctx context.Context, accountID *uint, id uint, req UpdateAgentBotRequest) (*model.AgentBot, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } var bot *model.AgentBot var err error if accountID != nil { bot, err = s.repo.FindByAccountAndID(ctx, *accountID, id) } else { bot, err = s.repo.GetByID(ctx, id) } if err != nil { applogger.L().Errorf("Update agent bot %d: get failed: %v", id, err) return nil, fmt.Errorf("agent bot not found: %w", err) } if req.Config == nil && req.BotConfig != nil { req.Config = req.BotConfig } if req.Name != nil { bot.Name = *req.Name } if req.Description != nil { bot.Description = *req.Description } if req.AvatarURL != nil { bot.AvatarURL = *req.AvatarURL } if req.OutgoingURL != nil { bot.OutgoingURL = *req.OutgoingURL } if req.BotType != nil { bot.BotType = *req.BotType } if req.Config != nil { bot.Config = req.Config } if err := s.repo.Update(ctx, bot); err != nil { applogger.L().Errorf("Update agent bot %d: %v", id, err) return nil, err } return bot, nil } // DeleteByAccount removes an account-owned AgentBot by ID. func (s *AgentBotService) DeleteByAccount(ctx context.Context, accountID uint, id uint) error { if _, err := s.repo.FindByAccountAndID(ctx, accountID, id); err != nil { return fmt.Errorf("agent bot not found: %w", err) } if err := s.repo.DeleteByAccount(ctx, accountID, id); err != nil { applogger.L().Errorf("Delete agent bot %d account %d: %v", id, accountID, err) return err } return nil } // Delete removes an AgentBot by ID. func (s *AgentBotService) Delete(ctx context.Context, id uint) error { if err := s.repo.Delete(ctx, id); err != nil { applogger.L().Errorf("Delete agent bot %d: %v", id, err) return err } applogger.L().Infof("Deleted agent bot %d", id) return nil } // ResetToken generates a new random access token for the bot, replacing the // previous one. Returns the new token string. // Reference: Chatwoot reset_access_token action on agent_bots_controller func (s *AgentBotService) ResetToken(ctx context.Context, id uint) (string, error) { bot, err := s.ResetTokenByAccount(ctx, 0, id) if err != nil { return "", err } return bot.AccessToken, nil } // ResetTokenByAccount regenerates the access token on an account-owned bot. func (s *AgentBotService) ResetTokenByAccount(ctx context.Context, accountID uint, id uint) (*model.AgentBot, error) { var bot *model.AgentBot var err error if accountID == 0 { bot, err = s.repo.GetByID(ctx, id) } else { bot, err = s.repo.FindByAccountAndID(ctx, accountID, id) } if err != nil { applogger.L().Errorf("ResetToken agent bot %d: get failed: %v", id, err) return nil, fmt.Errorf("agent bot not found: %w", err) } newToken, err := generateBotAccessToken() if err != nil { return nil, fmt.Errorf("failed to generate new access token: %w", err) } bot.AccessToken = newToken if err := s.repo.Update(ctx, bot); err != nil { applogger.L().Errorf("ResetToken agent bot %d: update failed: %v", id, err) return nil, err } applogger.L().Infof("Reset access token for agent bot %d", id) return bot, nil } // ResetSecret generates a new random webhook signing secret for the bot. // Reference: Chatwoot AgentBotsController#reset_secret — regenerates the secret used for HMAC signing func (s *AgentBotService) ResetSecret(ctx context.Context, id uint) (string, error) { bot, err := s.ResetSecretByAccount(ctx, 0, id) if err != nil { return "", err } return bot.Secret, nil } // ResetSecretByAccount regenerates the webhook secret on an account-owned bot. func (s *AgentBotService) ResetSecretByAccount(ctx context.Context, accountID uint, id uint) (*model.AgentBot, error) { var bot *model.AgentBot var err error if accountID == 0 { bot, err = s.repo.GetByID(ctx, id) } else { bot, err = s.repo.FindByAccountAndID(ctx, accountID, id) } if err != nil { applogger.L().Errorf("ResetSecret agent bot %d: get failed: %v", id, err) return nil, fmt.Errorf("agent bot not found: %w", err) } newSecret, err := generateBotSecret() if err != nil { return nil, fmt.Errorf("failed to generate new secret: %w", err) } bot.Secret = newSecret if err := s.repo.Update(ctx, bot); err != nil { applogger.L().Errorf("ResetSecret agent bot %d: update failed: %v", id, err) return nil, err } applogger.L().Infof("Reset webhook secret for agent bot %d", id) return bot, nil } // DeleteAvatar removes the bot's avatar URL (sets it to empty string). // Reference: Chatwoot AgentBotsController#destroy_avatar func (s *AgentBotService) DeleteAvatar(ctx context.Context, id uint) (*model.AgentBot, error) { return s.deleteAvatar(ctx, nil, id) } // DeleteAvatarByAccount removes the avatar from an account-owned bot. func (s *AgentBotService) DeleteAvatarByAccount(ctx context.Context, accountID uint, id uint) (*model.AgentBot, error) { return s.deleteAvatar(ctx, &accountID, id) } func (s *AgentBotService) deleteAvatar(ctx context.Context, accountID *uint, id uint) (*model.AgentBot, error) { var bot *model.AgentBot var err error if accountID != nil { bot, err = s.repo.FindByAccountAndID(ctx, *accountID, id) } else { bot, err = s.repo.GetByID(ctx, id) } if err != nil { return nil, fmt.Errorf("agent bot not found: %w", err) } bot.AvatarURL = "" if err := s.repo.Update(ctx, bot); err != nil { applogger.L().Errorf("DeleteAvatar agent bot %d: %v", id, err) return nil, err } applogger.L().Infof("Deleted avatar for agent bot %d", id) return bot, nil } // AgentBotUpdateAvatarRequest is the DTO for updating an agent bot's avatar. type AgentBotUpdateAvatarRequest struct { AvatarURL string `json:"avatar_url" validate:"required,url"` } // UpdateAvatar sets a new avatar URL for the bot. // Reference: Chatwoot AgentBotsController#update (avatar-only update via platform API) func (s *AgentBotService) UpdateAvatar(ctx context.Context, id uint, req AgentBotUpdateAvatarRequest) (*model.AgentBot, error) { bot, err := s.repo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("agent bot not found: %w", err) } bot.AvatarURL = req.AvatarURL if err := s.repo.Update(ctx, bot); err != nil { applogger.L().Errorf("UpdateAvatar agent bot %d: %v", id, err) return nil, err } applogger.L().Infof("Updated avatar for agent bot %d", id) return bot, nil } // ResetConfig clears the bot's configuration (sets Config to null). // Reference: Chatwoot AgentBotsController#reset (platform-level config reset) func (s *AgentBotService) ResetConfig(ctx context.Context, id uint) (*model.AgentBot, error) { bot, err := s.repo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("agent bot not found: %w", err) } bot.Config = json.RawMessage("null") if err := s.repo.Update(ctx, bot); err != nil { applogger.L().Errorf("ResetConfig agent bot %d: %v", id, err) return nil, err } applogger.L().Infof("Reset config for agent bot %d", id) return bot, nil } // ErrBotNotFound is returned when the bot cannot be found. var ErrBotNotFound = errors.New("agent bot not found")