Files
gochat/internal/automation/bot_rule_service.go
T

212 lines
7.2 KiB
Go

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
searchIndexer ActionSearchIndexer
}
// NewBotRuleService creates a new BotRuleService.
func NewBotRuleService(db DBProvider) *BotRuleService {
return &BotRuleService{db: db}
}
func (s *BotRuleService) SetSearchIndexer(indexer ActionSearchIndexer) {
s.searchIndexer = indexer
}
// 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)
actionSvc.SetSearchIndexer(s.searchIndexer)
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
}