1134 lines
40 KiB
Go
1134 lines
40 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
|
|
}
|
|
|
|
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 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) 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) 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) (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 = ? 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
|
|
}
|
|
}
|