475 lines
17 KiB
Go
475 lines
17 KiB
Go
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
|
|
}
|
|
} |