Files
gochat/internal/service/agent_bot_service.go
T

393 lines
13 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"
)
// 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")