package automation import ( "context" "fmt" "time" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // ActionSource identifies who triggered the action execution. // Reference: Chatwoot stamps automation_rule_id for rule actions, user info for macro actions type ActionSource string const ( ActionSourceAutomation ActionSource = "automation_rule" ActionSourceMacro ActionSource = "macro" ActionSourceUser ActionSource = "user" ActionSourceBotRule ActionSource = "bot_rule" // AgentBot automation rule execution ) // ActionService provides the shared base for executing automation and macro actions. // Reference: Chatwoot ActionService — 14 handlers shared between AutomationRules and Macros. // AutomationRules::ActionService stamps automation_rule_id. Macros::ExecutionService stamps user info. type ActionService struct { db DBProvider } // NewActionService creates a new ActionService. func NewActionService(db DBProvider) *ActionService { return &ActionService{db: db} } // Execute runs a single action on a conversation. // source and sourceID provide the audit trail (automation_rule_id or macro_id or user_id). // Template variables in action params (e.g. {{contact.name}}) are resolved before execution. func (s *ActionService) Execute(ctx context.Context, accountID uint, conversationID uint, action Action, source ActionSource, sourceID uint) error { applogger.L().Infof("executing action %s (source=%s, sourceID=%d) on conversation %d", action.ActionName, source, sourceID, conversationID) // Resolve template variables in action params before executing the action. // This allows actions like send_message to use {{contact.name}}, {{conversation.status}}, etc. resolvedAction, err := s.resolveTemplateVars(ctx, conversationID, action) if err != nil { applogger.L().Warnf("template variable resolution failed for action %s on conversation %d: %v", action.ActionName, conversationID, err) // Continue with unresolved params — template resolution failure should not block action execution resolvedAction = action } switch resolvedAction.ActionName { case "send_message": return s.handleSendMessage(ctx, accountID, conversationID, resolvedAction, source, sourceID) case "add_label": return s.handleAddLabel(ctx, accountID, conversationID, resolvedAction) case "remove_label": return s.handleRemoveLabel(ctx, accountID, conversationID, resolvedAction) case "assign_agent": return s.handleAssignAgent(ctx, accountID, conversationID, resolvedAction) case "assign_team": return s.handleAssignTeam(ctx, accountID, conversationID, resolvedAction) case "remove_assigned_agent": return s.handleRemoveAssignedAgent(ctx, accountID, conversationID) case "remove_assigned_team": return s.handleRemoveAssignedTeam(ctx, accountID, conversationID) case "send_webhook_event": return s.handleSendWebhookEvent(ctx, accountID, conversationID, resolvedAction) case "mute_conversation": return s.handleMuteConversation(ctx, accountID, conversationID) case "change_status": return s.handleChangeStatus(ctx, accountID, conversationID, resolvedAction) case "resolve_conversation": return s.handleChangeStatus(ctx, accountID, conversationID, Action{ ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}, }) case "open_conversation": return s.handleChangeStatus(ctx, accountID, conversationID, Action{ ActionName: "change_status", ActionParams: map[string]interface{}{"status": "open"}, }) case "pending_conversation": return s.handleChangeStatus(ctx, accountID, conversationID, Action{ ActionName: "change_status", ActionParams: map[string]interface{}{"status": "pending"}, }) case "snooze_conversation": return s.handleChangeStatus(ctx, accountID, conversationID, Action{ ActionName: "change_status", ActionParams: map[string]interface{}{"status": "snoozed"}, }) case "change_priority": return s.handleChangePriority(ctx, accountID, conversationID, resolvedAction) case "send_email_transcript": return s.handleSendEmailTranscript(ctx, accountID, conversationID, resolvedAction) case "send_attachment": return s.handleSendAttachment(ctx, accountID, conversationID, resolvedAction) case "add_private_note": return s.handleAddPrivateNote(ctx, accountID, conversationID, resolvedAction, source, sourceID) default: return fmt.Errorf("unsupported action: %s", resolvedAction.ActionName) } } // resolveTemplateVars builds a TemplateContext for the given conversation and resolves // all {{variable}} placeholders in the action's params. func (s *ActionService) resolveTemplateVars(ctx context.Context, conversationID uint, action Action) (Action, error) { builder := NewTemplateContextBuilder(s.db) tctx, err := builder.Build(ctx, conversationID) if err != nil { return action, err } resolvedParams := ResolveActionParams(action.ActionParams, tctx) return Action{ ActionName: action.ActionName, ActionParams: resolvedParams, }, nil } // =========================== // Action Handlers // =========================== // handleSendMessage sends a message to the conversation. // Reference: Chatwoot send_message action — creates an outgoing message in the conversation func (s *ActionService) handleSendMessage(ctx context.Context, accountID, conversationID uint, action Action, source ActionSource, sourceID uint) error { content, _ := action.ActionParams["content"].(string) if content == "" { return fmt.Errorf("send_message action requires 'content' param") } // Determine sender: for automation, use the bot/user who owns the rule senderID := sourceID senderType := "bot" if source == ActionSourceMacro || source == ActionSourceUser { senderType = "agent" } msg := &model.Message{ ConversationID: conversationID, AccountID: accountID, SenderID: &senderID, SenderType: senderType, Content: content, ContentType: "text", MessageType: "outgoing", Private: false, } return s.db.DB().WithContext(ctx).Create(msg).Error } // handleAddLabel adds a label to the conversation. // Reference: Chatwoot add_label action — adds tag/label to conversation func (s *ActionService) handleAddLabel(ctx context.Context, accountID, conversationID uint, action Action) error { labels := extractStringSlice(action.ActionParams, "labels") if len(labels) == 0 { // Single label param label, _ := action.ActionParams["label"].(string) if label == "" { return fmt.Errorf("add_label action requires 'label' or 'labels' param") } labels = []string{label} } // Create conversation_label records for each label for _, label := range labels { cl := &ConversationLabel{ ConversationID: conversationID, Label: label, } if err := s.db.DB().WithContext(ctx).Where(cl).FirstOrCreate(cl).Error; err != nil { applogger.L().Errorf("failed to add label '%s' to conversation %d: %v", label, conversationID, err) // Continue with remaining labels } } return nil } // handleRemoveLabel removes a label from the conversation. // Reference: Chatwoot remove_label action func (s *ActionService) handleRemoveLabel(ctx context.Context, accountID, conversationID uint, action Action) error { labels := extractStringSlice(action.ActionParams, "labels") if len(labels) == 0 { label, _ := action.ActionParams["label"].(string) if label == "" { return fmt.Errorf("remove_label action requires 'label' or 'labels' param") } labels = []string{label} } for _, label := range labels { if err := s.db.DB().WithContext(ctx). Where("conversation_id = ? AND label = ?", conversationID, label). Delete(&ConversationLabel{}).Error; err != nil { applogger.L().Errorf("failed to remove label '%s' from conversation %d: %v", label, conversationID, err) } } return nil } // handleAssignAgent assigns an agent to the conversation. // Reference: Chatwoot assign_agent action — sets assignee_id on conversation func (s *ActionService) handleAssignAgent(ctx context.Context, accountID, conversationID uint, action Action) error { assigneeID := extractUintParam(action.ActionParams, "assignee_id") // Support "self" assignment for macros — assigns the user executing the macro assigneeIDStr, _ := action.ActionParams["assignee_id"].(string) if assigneeIDStr == "self" { // For macros, sourceID is the user executing the macro assigneeID = sourceID(action) } return s.db.DB().WithContext(ctx). Model(&model.Conversation{}). Where("id = ? AND account_id = ?", conversationID, accountID). Update("assignee_id", assigneeID).Error } // handleAssignTeam assigns a team to the conversation. // Reference: Chatwoot assign_team action — sets team_id on conversation func (s *ActionService) handleAssignTeam(ctx context.Context, accountID, conversationID uint, action Action) error { teamID := extractUintParam(action.ActionParams, "team_id") return s.db.DB().WithContext(ctx). Model(&model.Conversation{}). Where("id = ? AND account_id = ?", conversationID, accountID). Update("team_id", teamID).Error } // handleRemoveAssignedAgent unassigns the agent from the conversation. // Reference: Chatwoot remove_assigned_agent action — sets assignee_id to NULL func (s *ActionService) handleRemoveAssignedAgent(ctx context.Context, accountID, conversationID uint) error { return s.db.DB().WithContext(ctx). Model(&model.Conversation{}). Where("id = ? AND account_id = ?", conversationID, accountID). Update("assignee_id", nil).Error } // handleRemoveAssignedTeam unassigns the team from the conversation. // Reference: Chatwoot remove_assigned_team action — sets team_id to NULL func (s *ActionService) handleRemoveAssignedTeam(ctx context.Context, accountID, conversationID uint) error { return s.db.DB().WithContext(ctx). Model(&model.Conversation{}). Where("id = ? AND account_id = ?", conversationID, accountID). Update("team_id", nil).Error } // handleSendWebhookEvent sends a webhook event for the conversation. // Reference: Chatwoot send_webhook_event action — POSTs to configured webhook URL func (s *ActionService) handleSendWebhookEvent(ctx context.Context, accountID, conversationID uint, action Action) error { url, _ := action.ActionParams["url"].(string) if url == "" { return fmt.Errorf("send_webhook_event action requires 'url' param") } // TODO: Implement webhook delivery in P10 async task processing // For now, log the webhook intent applogger.L().Infof("webhook event to %s for conversation %d (TODO: async delivery)", url, conversationID) return nil } // handleMuteConversation mutes notifications for the conversation. // Reference: Chatwoot mute_conversation action — marks conversation as muted func (s *ActionService) handleMuteConversation(ctx context.Context, accountID, conversationID uint) error { muted := &ConversationMute{ ConversationID: conversationID, AccountID: accountID, } return s.db.DB().WithContext(ctx).Where(muted).FirstOrCreate(muted).Error } // handleChangeStatus changes the conversation status. // Reference: Chatwoot change_status action — updates conversation status func (s *ActionService) handleChangeStatus(ctx context.Context, accountID, conversationID uint, action Action) error { status, _ := action.ActionParams["status"].(string) if status == "" { return fmt.Errorf("change_status action requires 'status' param") } // Validate status against known values validStatuses := map[string]bool{ "open": true, "resolved": true, "pending": true, "snoozed": true, } if !validStatuses[status] { return fmt.Errorf("invalid status '%s'", status) } return s.db.DB().WithContext(ctx). Model(&model.Conversation{}). Where("id = ? AND account_id = ?", conversationID, accountID). Update("status", status).Error } // handleChangePriority changes the conversation priority. // Reference: Chatwoot change_priority action func (s *ActionService) handleChangePriority(ctx context.Context, accountID, conversationID uint, action Action) error { priority, _ := action.ActionParams["priority"].(string) if priority == "" { return fmt.Errorf("change_priority action requires 'priority' param") } validPriorities := map[string]bool{ "urgent": true, "high": true, "medium": true, "low": true, } if !validPriorities[priority] { return fmt.Errorf("invalid priority '%s'", priority) } // Note: the conversation model doesn't currently have a priority field // This will be added in a future migration. For now, we store it in metadata. applogger.L().Infof("change_priority to '%s' for conversation %d", priority, conversationID) // Update priority field if it exists on the conversations table return s.db.DB().WithContext(ctx). Model(&model.Conversation{}). Where("id = ? AND account_id = ?", conversationID, accountID). Update("priority", priority).Error } // handleSendEmailTranscript sends an email transcript of the conversation. // Reference: Chatwoot send_email_transcript action func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID, conversationID uint, action Action) error { email, _ := action.ActionParams["email"].(string) if email == "" { return fmt.Errorf("send_email_transcript action requires 'email' param") } // TODO: Implement email transcript delivery — requires email service integration applogger.L().Infof("email transcript to %s for conversation %d (TODO: email service)", email, conversationID) return nil } // handleSendAttachment sends an attachment message to the conversation. // Reference: Chatwoot send_attachment action func (s *ActionService) handleSendAttachment(ctx context.Context, accountID, conversationID uint, action Action) error { attachmentURL, _ := action.ActionParams["attachment_url"].(string) if attachmentURL == "" { return fmt.Errorf("send_attachment action requires 'attachment_url' param") } // Create a message with attachment content type senderID := uint(0) // Bot sender msg := &model.Message{ ConversationID: conversationID, AccountID: accountID, SenderID: &senderID, SenderType: "bot", Content: attachmentURL, ContentType: "file", MessageType: "outgoing", Private: false, } return s.db.DB().WithContext(ctx).Create(msg).Error } // handleAddPrivateNote adds a private note (internal comment) to the conversation. // Reference: Chatwoot add_private_note action func (s *ActionService) handleAddPrivateNote(ctx context.Context, accountID, conversationID uint, action Action, source ActionSource, sourceID uint) error { content, _ := action.ActionParams["content"].(string) if content == "" { return fmt.Errorf("add_private_note action requires 'content' param") } msg := &model.Message{ ConversationID: conversationID, AccountID: accountID, SenderID: &sourceID, SenderType: "agent", Content: content, ContentType: "text", MessageType: "outgoing", Private: true, } return s.db.DB().WithContext(ctx).Create(msg).Error } // =========================== // Helper types for action side-effects // =========================== // ConversationLabel represents a label attached to a conversation. // Reference: Chatwoot's Tagging / Label system type ConversationLabel struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ConversationID uint `gorm:"index;not null" json:"conversation_id"` Label string `gorm:"size:255;index;not null" json:"label"` } func (ConversationLabel) TableName() string { return "conversation_labels" } // ConversationMute represents a muted conversation. // Reference: Chatwoot ConversationMute model type ConversationMute struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ConversationID uint `gorm:"index;not null" json:"conversation_id"` AccountID uint `gorm:"index;not null" json:"account_id"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` } func (ConversationMute) TableName() string { return "conversation_mutes" } // =========================== // Helpers // =========================== // extractStringSlice extracts a string slice from action params, handling both // []string and []interface{} (from JSON deserialization) formats. func extractStringSlice(params map[string]interface{}, key string) []string { raw, ok := params[key] if !ok { return nil } switch v := raw.(type) { case []string: return v case []interface{}: result := make([]string, len(v)) for i, item := range v { result[i] = fmt.Sprintf("%v", item) } return result default: return nil } } // extractUintParam extracts a uint parameter from action params. // Handles both numeric and string representations. func extractUintParam(params map[string]interface{}, key string) uint { raw, ok := params[key] if !ok { return 0 } switch v := raw.(type) { case uint: return v case int: return uint(v) case float64: return uint(v) case string: // "self" is handled separately in assign_agent return 0 default: return 0 } } // sourceID extracts the source ID from action params for "self" assignment. func sourceID(action Action) uint { id, ok := action.ActionParams["_source_user_id"] if !ok { return 0 } switch v := id.(type) { case uint: return v case int: return uint(v) case float64: return uint(v) default: return 0 } }