Files
gochat/backend/internal/automation/action_service.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

1292 lines
46 KiB
Go

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
searchIndexer ActionSearchIndexer
}
// ActionSearchIndexer is the narrow search synchronization boundary needed by
// automation and macro actions. service.DurableSearchIndexer satisfies it.
type ActionSearchIndexer interface {
IndexConversation(ctx context.Context, conversation *model.Conversation) error
IndexMessage(ctx context.Context, message *model.Message) error
IndexContact(ctx context.Context, contact *model.Contact) error
}
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 (s *ActionService) SetSearchIndexer(indexer ActionSearchIndexer) {
s.searchIndexer = indexer
}
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
var messageIDs []uint
indexConversation := false
indexContact := false
switch resolvedAction.ActionName {
case "send_message":
var msg *model.Message
msg, err = s.handleSendMessage(ctx, accountID, conversationID, resolvedAction, source, sourceID)
if msg != nil {
messageIDs = append(messageIDs, msg.ID)
indexConversation = true
}
case "send_email_to_team":
deliveryResult, err = s.handleSendEmailToTeam(ctx, accountID, conversationID, resolvedAction)
case "add_label":
err = s.handleAddLabel(ctx, accountID, conversationID, resolvedAction)
indexConversation = true
case "remove_label":
err = s.handleRemoveLabel(ctx, accountID, conversationID, resolvedAction)
indexConversation = true
case "assign_agent":
err = s.handleAssignAgent(ctx, accountID, conversationID, resolvedAction)
indexConversation = true
case "assign_team":
err = s.handleAssignTeam(ctx, accountID, conversationID, resolvedAction)
indexConversation = true
case "remove_assigned_agent":
err = s.handleRemoveAssignedAgent(ctx, accountID, conversationID)
indexConversation = true
case "remove_assigned_team":
err = s.handleRemoveAssignedTeam(ctx, accountID, conversationID)
indexConversation = true
case "send_webhook_event":
deliveryResult, err = s.handleSendWebhookEvent(ctx, accountID, conversationID, resolvedAction, source)
case "mute_conversation":
err = s.handleMuteConversation(ctx, accountID, conversationID)
indexConversation = true
indexContact = true
case "change_status":
err = s.handleChangeStatus(ctx, accountID, conversationID, resolvedAction)
indexConversation = true
case "resolve_conversation":
err = s.handleChangeStatus(ctx, accountID, conversationID, Action{
ActionName: "change_status",
ActionParams: map[string]interface{}{"status": "resolved"},
})
indexConversation = true
case "open_conversation":
err = s.handleChangeStatus(ctx, accountID, conversationID, Action{
ActionName: "change_status",
ActionParams: map[string]interface{}{"status": "open"},
})
indexConversation = true
case "pending_conversation":
err = s.handleChangeStatus(ctx, accountID, conversationID, Action{
ActionName: "change_status",
ActionParams: map[string]interface{}{"status": "pending"},
})
indexConversation = true
case "snooze_conversation":
err = s.handleChangeStatus(ctx, accountID, conversationID, Action{
ActionName: "change_status",
ActionParams: map[string]interface{}{"status": "snoozed"},
})
indexConversation = true
case "change_priority":
err = s.handleChangePriority(ctx, accountID, conversationID, resolvedAction)
indexConversation = true
case "add_sla":
err = s.handleAddSla(ctx, accountID, conversationID, resolvedAction)
indexConversation = true
case "send_email_transcript":
deliveryResult, err = s.handleSendEmailTranscript(ctx, accountID, conversationID, resolvedAction)
case "send_attachment":
var msg *model.Message
msg, err = s.handleSendAttachment(ctx, accountID, conversationID, resolvedAction)
if msg != nil {
messageIDs = append(messageIDs, msg.ID)
indexConversation = true
}
case "add_private_note":
var msg *model.Message
msg, err = s.handleAddPrivateNote(ctx, accountID, conversationID, resolvedAction, source, sourceID)
if msg != nil {
messageIDs = append(messageIDs, msg.ID)
indexConversation = true
}
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
}
s.indexActionSearchDocuments(ctx, accountID, conversationID, messageIDs, indexConversation, indexContact)
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) (*model.Message, error) {
content, _ := action.ActionParams["content"].(string)
if content == "" {
return nil, 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,
}
if err := s.db.DB().WithContext(ctx).Create(msg).Error; err != nil {
return nil, err
}
return msg, nil
}
// 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
}
}
if err := s.mergeConversationLabels(ctx, accountID, conversationID, labels, nil); err != nil {
return err
}
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)
}
}
if err := s.mergeConversationLabels(ctx, accountID, conversationID, nil, labels); err != nil {
return 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, source ActionSource) (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")
webhookEvent := webhookEventForActionSource(source, eventName)
payload, err := s.buildAutomationWebhookPayload(ctx, accountID, conversationID, eventName, webhookEvent)
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,
WebhookEvent: webhookEvent,
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,
WebhookEvent: webhookEvent,
URL: url,
Payload: payload,
})
}
func webhookEventForActionSource(source ActionSource, eventName string) string {
if source == ActionSourceMacro {
return "macro.executed"
}
return fmt.Sprintf("automation_event.%s", eventName)
}
// handleMuteConversation mutes notifications for the conversation.
// Reference: Chatwoot ActionService#mute_conversation delegates to Conversation#mute!.
func (s *ActionService) handleMuteConversation(ctx context.Context, accountID, conversationID uint) error {
db := s.db.DB().WithContext(ctx)
return db.Transaction(func(tx *gorm.DB) error {
var conversation model.Conversation
if err := tx.Where("id = ? AND account_id = ?", conversationID, accountID).First(&conversation).Error; err != nil {
return err
}
if conversation.ContactID != 0 {
if err := tx.Model(&model.Contact{}).
Where("id = ? AND account_id = ?", conversation.ContactID, accountID).
Update("blocked", true).Error; err != nil {
return err
}
}
return tx.Model(&model.Conversation{}).
Where("id = ? AND account_id = ?", conversationID, accountID).
Updates(map[string]any{"status": string(model.ConversationStatusResolved), "muted": true}).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")
}
account, err := s.loadTranscriptAccount(ctx, accountID)
if err != nil {
return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}, err
}
if !account.EmailTranscriptEnabled() {
return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ","), ResponseBody: "email_transcript_disabled"}, nil
}
if s.worker != nil {
for _, recipient := range recipients {
allowed, err := s.consumeTranscriptEmailQuota(ctx, account, time.Now())
if err != nil {
return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}, err
}
if !allowed {
break
}
_, 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 {
allowed, err := s.consumeTranscriptEmailQuota(ctx, account, time.Now())
if err != nil {
return aggregate, err
}
if !allowed {
break
}
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) (*model.Message, 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 nil, 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 nil, err
}
return s.createAttachmentMessage(ctx, accountID, conversationID, sourceID(action), upload)
}
func (s *ActionService) createAttachmentMessage(ctx context.Context, accountID, conversationID, senderID uint, upload model.DirectUpload) (*model.Message, 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 nil, 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",
}
if err := 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
}); err != nil {
return nil, err
}
return msg, nil
}
// 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) (*model.Message, error) {
content, _ := action.ActionParams["content"].(string)
if content == "" {
return nil, 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,
}
if err := s.db.DB().WithContext(ctx).Create(msg).Error; err != nil {
return nil, err
}
return msg, nil
}
func (s *ActionService) indexActionSearchDocuments(ctx context.Context, accountID, conversationID uint, messageIDs []uint, indexConversation bool, indexContact bool) {
if s.searchIndexer == nil {
return
}
for _, messageID := range messageIDs {
if messageID == 0 {
continue
}
var message model.Message
if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", messageID, accountID).First(&message).Error; err != nil {
applogger.L().Warnf("search index sync failed for automation message %d: %v", messageID, err)
continue
}
if err := s.searchIndexer.IndexMessage(ctx, &message); err != nil {
applogger.L().Warnf("search index sync failed for automation message %d: %v", messageID, err)
}
}
if !indexConversation && !indexContact {
return
}
var conversation model.Conversation
if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", conversationID, accountID).First(&conversation).Error; err != nil {
applogger.L().Warnf("search index sync failed for automation conversation %d: %v", conversationID, err)
return
}
if indexConversation {
if err := s.searchIndexer.IndexConversation(ctx, &conversation); err != nil {
applogger.L().Warnf("search index sync failed for automation conversation %d: %v", conversation.ID, err)
}
}
if indexContact && conversation.ContactID != 0 {
var contact model.Contact
if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", conversation.ContactID, accountID).First(&contact).Error; err != nil {
applogger.L().Warnf("search index sync failed for automation contact %d: %v", conversation.ContactID, err)
return
}
if err := s.searchIndexer.IndexContact(ctx, &contact); err != nil {
applogger.L().Warnf("search index sync failed for automation contact %d: %v", contact.ID, err)
}
}
}
func (s *ActionService) mergeConversationLabels(ctx context.Context, accountID, conversationID uint, add []string, remove []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
}
merged := mergeActionConversationLabels(conversation.Labels, add, remove)
return s.db.DB().WithContext(ctx).Model(&model.Conversation{}).Where("id = ? AND account_id = ?", conversationID, accountID).Update("labels", merged).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
}
}
func mergeActionConversationLabels(existing string, add []string, remove []string) string {
labels := map[string]bool{}
order := []string{}
for _, label := range strings.Split(existing, ",") {
label = strings.TrimSpace(label)
if label == "" || labels[label] {
continue
}
labels[label] = true
order = append(order, label)
}
for _, label := range add {
label = strings.TrimSpace(label)
if label == "" || labels[label] {
continue
}
labels[label] = true
order = append(order, label)
}
removeSet := map[string]bool{}
for _, label := range remove {
label = strings.TrimSpace(label)
if label != "" {
removeSet[label] = true
}
}
merged := make([]string, 0, len(order))
for _, label := range order {
if !removeSet[label] {
merged = append(merged, label)
}
}
return strings.Join(merged, ",")
}
// 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) loadTranscriptAccount(ctx context.Context, accountID uint) (*model.Account, error) {
var account model.Account
if err := s.db.DB().WithContext(ctx).First(&account, accountID).Error; err != nil {
return nil, err
}
return &account, nil
}
func (s *ActionService) consumeTranscriptEmailQuota(ctx context.Context, account *model.Account, now time.Time) (bool, error) {
if account == nil {
return false, fmt.Errorf("account is required")
}
if limit := account.EmailRateLimit(); limit > 0 && account.EmailsSentToday(now) >= limit {
return false, nil
}
if err := account.IncrementEmailSentCount(now); err != nil {
return false, err
}
if err := s.db.DB().WithContext(ctx).Model(&model.Account{}).Where("id = ?", account.ID).Update("custom_attributes", account.CustomAttributes).Error; err != nil {
return false, err
}
return true, nil
}
func (s *ActionService) buildAutomationWebhookPayload(ctx context.Context, accountID, conversationID uint, eventName string, webhookEvent 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": webhookEvent,
"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 = ? AND message_type IN ?", conversationID, accountID, false, []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)}).
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
}
}