package automation import ( "context" "fmt" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/gorm" ) // BotRuleService provides CRUD + condition matching + action execution for bot rules. // Reference: Chatwoot AutomationRules pattern adapted for AgentBot scope. // BotRuleService reuses ConditionsFilterService.MatchConditions and ActionService.Execute // to avoid duplicating condition matching and action execution logic. type BotRuleService struct { db DBProvider } // NewBotRuleService creates a new BotRuleService. func NewBotRuleService(db DBProvider) *BotRuleService { return &BotRuleService{db: db} } // GetByID retrieves a bot rule by ID. func (s *BotRuleService) GetByID(ctx context.Context, id uint) (*BotRule, error) { var rule BotRule if err := s.db.DB().WithContext(ctx).First(&rule, id).Error; err != nil { return nil, err } return &rule, nil } // ListByAccount retrieves all bot rules for an account. func (s *BotRuleService) ListByAccount(ctx context.Context, accountID uint) ([]BotRule, error) { var rules []BotRule if err := s.db.DB().WithContext(ctx). Where("account_id = ?", accountID). Order("id ASC"). Find(&rules).Error; err != nil { return nil, err } return rules, nil } // ListByAgentBot retrieves all bot rules for a specific AgentBot. func (s *BotRuleService) ListByAgentBot(ctx context.Context, accountID uint, agentBotID uint) ([]BotRule, error) { var rules []BotRule if err := s.db.DB().WithContext(ctx). Where("account_id = ? AND agent_bot_id = ?", accountID, agentBotID). Order("id ASC"). Find(&rules).Error; err != nil { return nil, err } return rules, nil } // ListActiveByAccountAndEvent retrieves active bot rules for a given account and event. // Reference: mirrors AutomationRuleService.ListActiveByAccountAndEvent func (s *BotRuleService) ListActiveByAccountAndEvent(ctx context.Context, accountID uint, eventName string) ([]BotRule, error) { var rules []BotRule if err := s.db.DB().WithContext(ctx). Where("account_id = ? AND event_name = ? AND status = ?", accountID, eventName, BotRuleStatusActive). Order("id ASC"). Find(&rules).Error; err != nil { return nil, err } return rules, nil } // Create creates a new bot rule. // New rules default to active status unless explicitly set to inactive. func (s *BotRuleService) Create(ctx context.Context, rule *BotRule) error { // Validate conditions before saving if err := ValidateConditions(rule.Conditions); err != nil { return fmt.Errorf("invalid conditions: %w", err) } // Use Select to force all fields including zero-value fields return s.db.DB().WithContext(ctx).Select( "AccountID", "AgentBotID", "EventName", "Name", "Description", "Conditions", "Actions", "Status", ).Create(rule).Error } // Update updates an existing bot rule. func (s *BotRuleService) Update(ctx context.Context, rule *BotRule) error { // Validate conditions before saving if err := ValidateConditions(rule.Conditions); err != nil { return fmt.Errorf("invalid conditions: %w", err) } return s.db.DB().WithContext(ctx).Save(rule).Error } // Delete soft-deletes a bot rule. func (s *BotRuleService) Delete(ctx context.Context, id uint) error { return s.db.DB().WithContext(ctx).Delete(&BotRule{}, id).Error } // Clone duplicates a bot rule, creating a new copy with "(copy)" appended to the name. func (s *BotRuleService) Clone(ctx context.Context, id uint) (*BotRule, error) { original, err := s.GetByID(ctx, id) if err != nil { return nil, err } cloned := &BotRule{ AccountID: original.AccountID, AgentBotID: original.AgentBotID, EventName: original.EventName, Name: original.Name + " (copy)", Description: original.Description, Conditions: original.Conditions, Actions: original.Actions, Status: original.Status, } if err := s.db.DB().WithContext(ctx).Create(cloned).Error; err != nil { return nil, err } return cloned, nil } // ToggleStatus toggles the active/inactive status of a bot rule. func (s *BotRuleService) ToggleStatus(ctx context.Context, id uint, status BotRuleStatus) error { return s.db.DB().WithContext(ctx). Model(&BotRule{}). Where("id = ?", id). Update("status", status).Error } // MatchAndExecute checks all active bot rules for the given event and account, // runs condition filters for each rule, and executes actions for matching rules. // Reference: mirrors AutomationRuleService.MatchAndExecute but for BotRule scope. // Reuses ConditionsFilterService.MatchConditions and ActionService.Execute. func (s *BotRuleService) MatchAndExecute(ctx context.Context, accountID uint, eventName string, conversationID uint, eventData map[string]interface{}) error { rules, err := s.ListActiveByAccountAndEvent(ctx, accountID, eventName) if err != nil { return fmt.Errorf("failed to load bot rules: %w", err) } if len(rules) == 0 { applogger.L().Debugf("no active bot rules for account %d event %s", accountID, eventName) return nil } // Load the conversation for condition matching conversation, err := loadConversationForFilter(ctx, s.db.DB(), conversationID) if err != nil { return fmt.Errorf("failed to load conversation %d: %w", conversationID, err) } actionSvc := NewActionService(s.db) for _, rule := range rules { matched, err := MatchConditions(ctx, s.db.DB(), rule.Conditions, conversation, eventData) if err != nil { applogger.L().Errorf("condition matching failed for bot rule %d: %v", rule.ID, err) continue } if !matched { applogger.L().Debugf("bot rule %d did not match conditions for conversation %d", rule.ID, conversationID) continue } applogger.L().Infof("bot rule %d matched for conversation %d, executing %d actions", rule.ID, conversationID, len(rule.Actions)) // Execute actions — stamp source as "bot_rule" with rule.ID for audit trail actionsExecuted := 0 actionsFailed := 0 var firstErrorMsg string for _, action := range rule.Actions { if err := actionSvc.Execute(ctx, accountID, conversationID, action, ActionSourceBotRule, rule.ID); err != nil { applogger.L().Errorf("action %s failed for bot rule %d on conversation %d: %v", action.ActionName, rule.ID, conversationID, err) actionsFailed++ if firstErrorMsg == "" { firstErrorMsg = err.Error() } } else { actionsExecuted++ } } if actionsFailed > 0 { applogger.L().Warnf("bot rule %d: %d/%d actions executed, %d failed (first error: %s)", rule.ID, actionsExecuted, len(rule.Actions), actionsFailed, firstErrorMsg) } else { applogger.L().Infof("bot rule %d: all %d actions executed successfully", rule.ID, actionsExecuted) } } return nil } // loadConversationForFilter loads a conversation for condition matching. // Standalone helper shared by BotRuleService and BotTriggerConfigService. // Reference: mirrors AutomationRuleService.loadConversation func loadConversationForFilter(ctx context.Context, db *gorm.DB, conversationID uint) (*ConversationForFilter, error) { var conv ConversationForFilter if err := db.WithContext(ctx). Table("conversations"). Where("id = ?", conversationID). First(&conv).Error; err != nil { return nil, err } return &conv, nil }