package automation import ( "context" "encoding/json" "fmt" "strconv" "strings" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/gorm" ) // 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 webhookDeliverer AutomationWebhookDeliverer transcriptDeliverer AutomationTranscriptDeliverer worker *worker.WorkerPool } var defaultWebhookDelivererFactory = func() AutomationWebhookDeliverer { return NewHTTPAutomationWebhookDeliverer(nil, defaultActionDeliveryAttempts, 0) } var defaultTranscriptDelivererFactory = func() AutomationTranscriptDeliverer { return NewEnvAutomationTranscriptDeliverer() } // NewActionService creates a new ActionService. func NewActionService(db DBProvider) *ActionService { return &ActionService{ db: db, webhookDeliverer: defaultWebhookDelivererFactory(), transcriptDeliverer: defaultTranscriptDelivererFactory(), } } func NewActionServiceWithWorker(db DBProvider, wp *worker.WorkerPool) *ActionService { s := NewActionService(db) s.SetWorkerPool(wp) return s } func (s *ActionService) SetWorkerPool(wp *worker.WorkerPool) { s.worker = wp RegisterActionDeliveryJobs(wp, s.db) } func setAutomationActionDeliverersForTest(webhook AutomationWebhookDeliverer, transcript AutomationTranscriptDeliverer) func() { originalWebhookFactory := defaultWebhookDelivererFactory originalTranscriptFactory := defaultTranscriptDelivererFactory defaultWebhookDelivererFactory = func() AutomationWebhookDeliverer { return webhook } defaultTranscriptDelivererFactory = func() AutomationTranscriptDeliverer { return transcript } return func() { defaultWebhookDelivererFactory = originalWebhookFactory defaultTranscriptDelivererFactory = originalTranscriptFactory } } // 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 { _, err := s.ExecuteWithResult(ctx, accountID, conversationID, action, source, sourceID) return err } // ExecuteWithResult runs a single action and returns metadata suitable for // AutomationExecution.action_results. func (s *ActionService) ExecuteWithResult(ctx context.Context, accountID uint, conversationID uint, action Action, source ActionSource, sourceID uint) (ActionExecutionResult, error) { applogger.L().Infof("executing action %s (source=%s, sourceID=%d) on conversation %d", action.ActionName, source, sourceID, conversationID) result := ActionExecutionResult{ActionName: action.ActionName} // 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 } result.ActionName = resolvedAction.ActionName var deliveryResult ActionDeliveryResult switch resolvedAction.ActionName { case "send_message": err = s.handleSendMessage(ctx, accountID, conversationID, resolvedAction, source, sourceID) case "send_email_to_team": deliveryResult, err = s.handleSendEmailToTeam(ctx, accountID, conversationID, resolvedAction) case "add_label": err = s.handleAddLabel(ctx, accountID, conversationID, resolvedAction) case "remove_label": err = s.handleRemoveLabel(ctx, accountID, conversationID, resolvedAction) case "assign_agent": err = s.handleAssignAgent(ctx, accountID, conversationID, resolvedAction) case "assign_team": err = s.handleAssignTeam(ctx, accountID, conversationID, resolvedAction) case "remove_assigned_agent": err = s.handleRemoveAssignedAgent(ctx, accountID, conversationID) case "remove_assigned_team": err = s.handleRemoveAssignedTeam(ctx, accountID, conversationID) case "send_webhook_event": deliveryResult, err = s.handleSendWebhookEvent(ctx, accountID, conversationID, resolvedAction) case "mute_conversation": err = s.handleMuteConversation(ctx, accountID, conversationID) case "change_status": err = s.handleChangeStatus(ctx, accountID, conversationID, resolvedAction) case "resolve_conversation": err = s.handleChangeStatus(ctx, accountID, conversationID, Action{ ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}, }) case "open_conversation": err = s.handleChangeStatus(ctx, accountID, conversationID, Action{ ActionName: "change_status", ActionParams: map[string]interface{}{"status": "open"}, }) case "pending_conversation": err = s.handleChangeStatus(ctx, accountID, conversationID, Action{ ActionName: "change_status", ActionParams: map[string]interface{}{"status": "pending"}, }) case "snooze_conversation": err = s.handleChangeStatus(ctx, accountID, conversationID, Action{ ActionName: "change_status", ActionParams: map[string]interface{}{"status": "snoozed"}, }) case "change_priority": err = s.handleChangePriority(ctx, accountID, conversationID, resolvedAction) case "add_sla": err = s.handleAddSla(ctx, accountID, conversationID, resolvedAction) case "send_email_transcript": deliveryResult, err = s.handleSendEmailTranscript(ctx, accountID, conversationID, resolvedAction) case "send_attachment": err = s.handleSendAttachment(ctx, accountID, conversationID, resolvedAction) case "add_private_note": err = s.handleAddPrivateNote(ctx, accountID, conversationID, resolvedAction, source, sourceID) default: err = fmt.Errorf("unsupported action: %s", resolvedAction.ActionName) } applyDeliveryResult(&result, deliveryResult) if err != nil { result.Status = ExecutionStatusFailed result.Error = err.Error() return result, err } result.Status = ExecutionStatusSuccess return result, nil } // 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 } // handleSendEmailToTeam sends Chatwoot-style automation team notifications. // Reference: AutomationRules::ActionService#send_email_to_team receives // { team_ids, message } and sends one email to each member of each team. func (s *ActionService) handleSendEmailToTeam(ctx context.Context, accountID, conversationID uint, action Action) (ActionDeliveryResult, error) { teamIDs := extractUintSlice(action.ActionParams, "team_ids", "team_id") message := firstStringParam(action.ActionParams, "message", "content") if len(teamIDs) == 0 { return ActionDeliveryResult{DeliveryType: "team_email"}, fmt.Errorf("send_email_to_team action requires 'team_ids' param") } if s.worker != nil { for _, teamID := range teamIDs { _, err := s.worker.Enqueue(ctx, TaskTypeAutomationTeamEmailDelivery, automationTeamEmailDeliveryJob{ AccountID: accountID, ConversationID: conversationID, TeamID: teamID, Message: message, }, worker.WithQueue("automation"), worker.WithMaxAttempts(defaultActionDeliveryAttempts)) if err != nil { return ActionDeliveryResult{DeliveryType: "team_email", Target: joinUintTargets(teamIDs)}, err } } return ActionDeliveryResult{DeliveryType: "team_email", Target: joinUintTargets(teamIDs), ResponseBody: "queued", Queued: true}, nil } requests, err := s.buildTeamEmailRequests(ctx, accountID, conversationID, teamIDs, message) if err != nil { return ActionDeliveryResult{DeliveryType: "team_email", Target: joinUintTargets(teamIDs)}, err } aggregate := ActionDeliveryResult{DeliveryType: "team_email", Target: joinUintTargets(teamIDs)} for _, req := range requests { result, err := s.transcriptDeliverer.DeliverTranscript(ctx, req) aggregate.Attempts += result.Attempts aggregate.ResponseCode = result.ResponseCode aggregate.ResponseBody = result.ResponseBody aggregate.Retryable = result.Retryable if err != nil { return aggregate, err } } return aggregate, nil } // 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 — conversation.webhook_data + automation event. func (s *ActionService) handleSendWebhookEvent(ctx context.Context, accountID, conversationID uint, action Action) (ActionDeliveryResult, error) { url := firstStringParam(action.ActionParams, "url", "webhook_url") if url == "" { values := extractStringSlice(action.ActionParams, "values") if len(values) > 0 { url = values[0] } } if url == "" { return ActionDeliveryResult{DeliveryType: "webhook"}, fmt.Errorf("send_webhook_event action requires 'url' param") } eventName := firstStringParam(action.ActionParams, "_event_name", "event_name") payload, err := s.buildAutomationWebhookPayload(ctx, accountID, conversationID, eventName) if err != nil { return ActionDeliveryResult{DeliveryType: "webhook", Target: url}, err } if s.worker != nil { _, err := s.worker.Enqueue(ctx, TaskTypeAutomationWebhookDelivery, automationWebhookDeliveryJob{ AccountID: accountID, ConversationID: conversationID, EventName: eventName, URL: url, Payload: payload, }, worker.WithQueue("automation"), worker.WithMaxAttempts(defaultActionDeliveryAttempts)) if err != nil { return ActionDeliveryResult{DeliveryType: "webhook", Target: url}, err } return ActionDeliveryResult{DeliveryType: "webhook", Target: url, ResponseBody: "queued", Queued: true}, nil } return s.webhookDeliverer.DeliverWebhook(ctx, AutomationWebhookRequest{ AccountID: accountID, ConversationID: conversationID, EventName: eventName, URL: url, Payload: payload, }) } // 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 } // handleAddSla attaches an account-scoped SLA policy when the conversation // does not already have one. Reference: Enterprise::ActionService#add_sla. func (s *ActionService) handleAddSla(ctx context.Context, accountID, conversationID uint, action Action) error { slaPolicyID := extractUintParam(action.ActionParams, "sla_policy_id") if slaPolicyID == 0 { return fmt.Errorf("add_sla action requires 'sla_policy_id' param") } var policy model.SlaPolicy if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", slaPolicyID, accountID).First(&policy).Error; err != nil { return fmt.Errorf("sla policy not found: %w", err) } var conversation model.Conversation if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", conversationID, accountID).First(&conversation).Error; err != nil { return fmt.Errorf("conversation not found: %w", err) } if conversation.SlaPolicyID != nil && *conversation.SlaPolicyID != 0 { return nil } return s.db.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.Conversation{}). Where("id = ? AND account_id = ? AND (sla_policy_id IS NULL OR sla_policy_id = 0)", conversationID, accountID). Update("sla_policy_id", slaPolicyID) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return nil } if err := tx.Where("id = ? AND account_id = ?", conversationID, accountID).First(&conversation).Error; err != nil { return err } var count int64 if err := tx.Model(&model.AppliedSLA{}).Where("account_id = ? AND conversation_id = ?", accountID, conversationID).Count(&count).Error; err != nil { return err } if count > 0 { return nil } applied := appliedSlaFromAutomationPolicy(accountID, conversationID, conversation, policy) return tx.Create(applied).Error }) } // handleSendEmailTranscript sends an email transcript of the conversation. // Reference: Chatwoot send_email_transcript action splits comma-separated emails. func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID, conversationID uint, action Action) (ActionDeliveryResult, error) { recipients := extractTranscriptRecipients(action.ActionParams) if len(recipients) == 0 { return ActionDeliveryResult{DeliveryType: "email_transcript"}, fmt.Errorf("send_email_transcript action requires 'email' param") } if s.worker != nil { for _, recipient := range recipients { _, err := s.worker.Enqueue(ctx, TaskTypeAutomationTranscriptDelivery, automationTranscriptDeliveryJob{ AccountID: accountID, ConversationID: conversationID, Recipient: recipient, }, worker.WithQueue("automation"), worker.WithMaxAttempts(defaultActionDeliveryAttempts)) if err != nil { return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}, err } } return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ","), ResponseBody: "queued", Queued: true}, nil } subject, body, err := s.buildTranscriptEmail(ctx, accountID, conversationID) if err != nil { return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}, err } aggregate := ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")} for _, recipient := range recipients { result, err := s.transcriptDeliverer.DeliverTranscript(ctx, AutomationTranscriptRequest{ AccountID: accountID, ConversationID: conversationID, Recipient: recipient, Subject: subject, Body: body, }) aggregate.Attempts += result.Attempts aggregate.ResponseCode = result.ResponseCode aggregate.ResponseBody = result.ResponseBody aggregate.Retryable = result.Retryable if err != nil { return aggregate, err } } return aggregate, 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 s.createAttachmentMessage(ctx, accountID, conversationID, sourceID(action), model.DirectUpload{FileURL: attachmentURL, OriginalName: attachmentURL, FileType: "file"}) } blobID := extractUintParam(action.ActionParams, "blob_id") if blobID == 0 { return fmt.Errorf("send_attachment action requires 'blob_id' param") } var upload model.DirectUpload if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, blobID).First(&upload).Error; err != nil { return err } return s.createAttachmentMessage(ctx, accountID, conversationID, sourceID(action), upload) } func (s *ActionService) createAttachmentMessage(ctx context.Context, accountID, conversationID, senderID uint, upload model.DirectUpload) error { var conversation model.Conversation if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, conversationID).First(&conversation).Error; err != nil { return err } senderType := "agent" if senderID == 0 { senderType = "bot" } msg := &model.Message{ ConversationID: conversationID, AccountID: accountID, InboxID: conversation.InboxID, SenderID: &senderID, SenderType: senderType, Content: "", ContentType: "file", MessageType: "outgoing", Private: false, Status: "sent", } return s.db.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(msg).Error; err != nil { return err } attachment := &model.Attachment{ MessageID: msg.ID, AccountID: accountID, FileType: upload.FileType, FileURL: upload.FileURL, ThumbURL: upload.ThumbURL, FileSize: int(upload.FileSize), FileName: upload.OriginalName, } if attachment.FileType == "" { attachment.FileType = "file" } return tx.Create(attachment).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 } } func extractUintSlice(params map[string]interface{}, keys ...string) []uint { for _, key := range keys { raw, ok := params[key] if !ok || raw == nil { continue } switch v := raw.(type) { case []uint: return v case []int: out := make([]uint, 0, len(v)) for _, item := range v { if item > 0 { out = append(out, uint(item)) } } return out case []interface{}: out := make([]uint, 0, len(v)) for _, item := range v { if parsed := uintFromAny(item); parsed > 0 { out = append(out, parsed) } } return out default: if parsed := uintFromAny(v); parsed > 0 { return []uint{parsed} } } } return nil } func uintFromAny(raw interface{}) uint { switch v := raw.(type) { case uint: return v case int: if v > 0 { return uint(v) } case int64: if v > 0 { return uint(v) } case float64: if v > 0 { return uint(v) } case string: parsed, err := strconv.ParseUint(strings.TrimSpace(v), 10, 64) if err == nil && parsed > 0 { return uint(parsed) } } return 0 } func joinUintTargets(values []uint) string { parts := make([]string, 0, len(values)) for _, value := range values { parts = append(parts, fmt.Sprintf("%d", value)) } return strings.Join(parts, ",") } func applyDeliveryResult(result *ActionExecutionResult, delivery ActionDeliveryResult) { if delivery.DeliveryType == "" && delivery.Target == "" && delivery.Attempts == 0 && delivery.ResponseCode == 0 && delivery.ResponseBody == "" && !delivery.Retryable { return } result.DeliveryType = delivery.DeliveryType result.Target = delivery.Target result.Attempts = delivery.Attempts result.ResponseCode = delivery.ResponseCode result.ResponseBody = delivery.ResponseBody result.Retryable = delivery.Retryable result.Queued = delivery.Queued } func firstStringParam(params map[string]interface{}, keys ...string) string { for _, key := range keys { if raw, ok := params[key]; ok { switch v := raw.(type) { case string: if trimmed := strings.TrimSpace(v); trimmed != "" { return trimmed } case []string: if len(v) > 0 { if trimmed := strings.TrimSpace(v[0]); trimmed != "" { return trimmed } } case []interface{}: if len(v) > 0 { if trimmed := strings.TrimSpace(fmt.Sprintf("%v", v[0])); trimmed != "" { return trimmed } } } } } return "" } func extractTranscriptRecipients(params map[string]interface{}) []string { var raw []string if email := firstStringParam(params, "email", "emails"); email != "" { raw = append(raw, email) } raw = append(raw, extractStringSlice(params, "values")...) seen := map[string]bool{} recipients := make([]string, 0, len(raw)) for _, value := range raw { for _, item := range strings.Split(value, ",") { recipient := strings.TrimSpace(item) if recipient == "" || seen[recipient] { continue } seen[recipient] = true recipients = append(recipients, recipient) } } return recipients } func (s *ActionService) buildAutomationWebhookPayload(ctx context.Context, accountID, conversationID uint, eventName string) (map[string]interface{}, error) { var conversation model.Conversation if err := s.db.DB().WithContext(ctx). Where("id = ? AND account_id = ?", conversationID, accountID). First(&conversation).Error; err != nil { return nil, err } var messages []model.Message _ = s.db.DB().WithContext(ctx). Where("conversation_id = ? AND account_id = ?", conversationID, accountID). Order("id DESC"). Limit(1). Find(&messages).Error messagePayloads := make([]map[string]interface{}, 0, len(messages)) for i := len(messages) - 1; i >= 0; i-- { messagePayloads = append(messagePayloads, automationMessageWebhookPayload(messages[i])) } displayID := conversation.ID if conversation.DisplayID != nil && *conversation.DisplayID > 0 { displayID = *conversation.DisplayID } createdAt := conversation.CreatedAt.Unix() updatedAt := float64(conversation.UpdatedAt.UnixNano()) / float64(time.Second) payload := map[string]interface{}{ "event": fmt.Sprintf("automation_event.%s", eventName), "additional_attributes": jsonObject(conversation.AdditionalAttributes), "custom_attributes": jsonObject(conversation.CustomAttributes), "id": displayID, "conversation_id": conversation.ID, "uuid": conversation.UUID, "account_id": conversation.AccountID, "inbox_id": conversation.InboxID, "contact_id": conversation.ContactID, "status": conversation.Status, "priority": conversation.Priority, "channel": conversation.ChannelType, "messages": messagePayloads, "labels": splitConversationLabels(conversation.Labels), "created_at": createdAt, "updated_at": updatedAt, } if conversation.AssigneeID != nil { payload["assignee_id"] = *conversation.AssigneeID } if conversation.TeamID != nil { payload["team_id"] = *conversation.TeamID } if conversation.LastActivityAt != nil { payload["last_activity_at"] = *conversation.LastActivityAt payload["timestamp"] = *conversation.LastActivityAt } return payload, nil } func automationMessageWebhookPayload(message model.Message) map[string]interface{} { payload := map[string]interface{}{ "id": message.ID, "content": message.Content, "message_type": message.MessageType, "content_type": message.ContentType, "private": message.Private, "conversation_id": message.ConversationID, "account_id": message.AccountID, "source_id": message.SourceID, "created_at": message.CreatedAt.Unix(), "updated_at": float64(message.UpdatedAt.UnixNano()) / float64(time.Second), "additional_attributes": jsonObject(message.AdditionalAttributes), } if message.SenderID != nil { payload["sender_id"] = *message.SenderID } if message.SenderType != "" { payload["sender_type"] = message.SenderType } return payload } func (s *ActionService) buildTranscriptEmail(ctx context.Context, accountID, conversationID uint) (string, string, error) { var conversation model.Conversation if err := s.db.DB().WithContext(ctx). Where("id = ? AND account_id = ?", conversationID, accountID). First(&conversation).Error; err != nil { return "", "", err } var messages []model.Message if err := s.db.DB().WithContext(ctx). Where("conversation_id = ? AND account_id = ? AND private = ?", conversationID, accountID, false). Order("id ASC"). Find(&messages).Error; err != nil { return "", "", err } displayID := conversation.ID if conversation.DisplayID != nil && *conversation.DisplayID > 0 { displayID = *conversation.DisplayID } subject := fmt.Sprintf("[#%d] Conversation Transcript", displayID) var body strings.Builder body.WriteString(fmt.Sprintf("Conversation #%d transcript\n\n", displayID)) for _, message := range messages { if strings.TrimSpace(message.Content) == "" { continue } body.WriteString(fmt.Sprintf("[%s] %s\n", message.MessageType, message.Content)) } return subject, body.String(), nil } func (s *ActionService) buildTeamEmailRequests(ctx context.Context, accountID, conversationID uint, teamIDs []uint, message string) ([]AutomationTranscriptRequest, error) { var conversation model.Conversation if err := s.db.DB().WithContext(ctx). Where("id = ? AND account_id = ?", conversationID, accountID). First(&conversation).Error; err != nil { return nil, err } displayID := conversation.ID if conversation.DisplayID != nil && *conversation.DisplayID > 0 { displayID = *conversation.DisplayID } subject := fmt.Sprintf("Conversation (#%d) automation notification", displayID) body := strings.TrimSpace(message) if body == "" { body = fmt.Sprintf("Conversation #%d matched an automation rule.", displayID) } requests := make([]AutomationTranscriptRequest, 0) seen := map[string]bool{} for _, teamID := range teamIDs { var team model.Team if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", teamID, accountID).First(&team).Error; err != nil { return nil, err } var users []model.User if err := s.db.DB().WithContext(ctx). Joins("INNER JOIN team_members ON team_members.user_id = users.id"). Joins("INNER JOIN account_users ON account_users.user_id = users.id AND account_users.account_id = ?", accountID). Where("team_members.team_id = ? AND users.email <> ''", teamID). Order("users.id ASC"). Find(&users).Error; err != nil { return nil, err } for _, user := range users { recipient := strings.TrimSpace(user.Email) if recipient == "" || seen[recipient] { continue } seen[recipient] = true requests = append(requests, AutomationTranscriptRequest{ AccountID: accountID, ConversationID: conversationID, Recipient: recipient, Subject: subject, Body: body, }) } } return requests, nil } func appliedSlaFromAutomationPolicy(accountID, conversationID uint, conversation model.Conversation, policy model.SlaPolicy) *model.AppliedSLA { applied := &model.AppliedSLA{AccountID: accountID, ConversationID: conversationID, SlaPolicyID: policy.ID, SLAStatus: model.SLAStatusActive} if policy.FirstResponseTimeThreshold > 0 { frt := conversation.CreatedAt.Add(time.Duration(policy.FirstResponseTimeThreshold) * time.Second) applied.FRTTargetAt = &frt } if policy.NextResponseTimeThreshold > 0 { if applied.FRTTargetAt != nil { nrt := applied.FRTTargetAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Second) applied.NRTTargetAt = &nrt } else { nrt := conversation.CreatedAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Second) applied.NRTTargetAt = &nrt } } if policy.ResolutionTimeThreshold > 0 { rt := conversation.CreatedAt.Add(time.Duration(policy.ResolutionTimeThreshold) * time.Second) applied.RTTargetAt = &rt } if conversation.FirstReplyCreatedAt != nil { frtActual := time.Unix(*conversation.FirstReplyCreatedAt, 0) applied.FRTActualAt = &frtActual } if conversation.ResolvedAt != nil { applied.RTActualAt = conversation.ResolvedAt } return applied } func jsonObject(raw []byte) map[string]interface{} { if len(raw) == 0 { return map[string]interface{}{} } var payload map[string]interface{} if err := json.Unmarshal(raw, &payload); err != nil || payload == nil { return map[string]interface{}{} } return payload } func splitConversationLabels(labels string) []string { if strings.TrimSpace(labels) == "" { return []string{} } var parsed []string if err := json.Unmarshal([]byte(labels), &parsed); err == nil { return parsed } parts := strings.Split(labels, ",") result := make([]string, 0, len(parts)) for _, part := range parts { if trimmed := strings.TrimSpace(part); trimmed != "" { result = append(result, trimmed) } } return result } // 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 } }