Files
gochat/internal/automation/action_service.go
T
2026-06-04 15:44:48 +08:00

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
}
}