feat(automation): deliver retryable external actions
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultActionDeliveryAttempts = 3
|
||||
defaultActionDeliveryTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// ActionDeliveryResult is copied into AutomationExecution.action_results so
|
||||
// external actions are inspectable even when the durable worker is not yet in use.
|
||||
type ActionDeliveryResult struct {
|
||||
DeliveryType string
|
||||
Target string
|
||||
Attempts int
|
||||
ResponseCode int
|
||||
ResponseBody string
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
type AutomationWebhookRequest struct {
|
||||
AccountID uint
|
||||
ConversationID uint
|
||||
EventName string
|
||||
URL string
|
||||
Payload map[string]interface{}
|
||||
}
|
||||
|
||||
type AutomationTranscriptRequest struct {
|
||||
AccountID uint
|
||||
ConversationID uint
|
||||
Recipient string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
type AutomationWebhookDeliverer interface {
|
||||
DeliverWebhook(ctx context.Context, req AutomationWebhookRequest) (ActionDeliveryResult, error)
|
||||
}
|
||||
|
||||
type AutomationTranscriptDeliverer interface {
|
||||
DeliverTranscript(ctx context.Context, req AutomationTranscriptRequest) (ActionDeliveryResult, error)
|
||||
}
|
||||
|
||||
type HTTPAutomationWebhookDeliverer struct {
|
||||
client *http.Client
|
||||
maxAttempts int
|
||||
retryDelay time.Duration
|
||||
}
|
||||
|
||||
func NewHTTPAutomationWebhookDeliverer(client *http.Client, maxAttempts int, retryDelay time.Duration) *HTTPAutomationWebhookDeliverer {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: defaultActionDeliveryTimeout}
|
||||
}
|
||||
if client.Timeout == 0 {
|
||||
client.Timeout = defaultActionDeliveryTimeout
|
||||
}
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = defaultActionDeliveryAttempts
|
||||
}
|
||||
return &HTTPAutomationWebhookDeliverer{client: client, maxAttempts: maxAttempts, retryDelay: retryDelay}
|
||||
}
|
||||
|
||||
func (d *HTTPAutomationWebhookDeliverer) DeliverWebhook(ctx context.Context, req AutomationWebhookRequest) (ActionDeliveryResult, error) {
|
||||
result := ActionDeliveryResult{DeliveryType: "webhook", Target: req.URL}
|
||||
if strings.TrimSpace(req.URL) == "" {
|
||||
return result, errors.New("webhook URL is required")
|
||||
}
|
||||
body, err := json.Marshal(req.Payload)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("marshal webhook payload: %w", err)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= d.maxAttempts; attempt++ {
|
||||
result.Attempts = attempt
|
||||
result.ResponseCode = 0
|
||||
result.ResponseBody = ""
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, d.client.Timeout)
|
||||
httpReq, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, req.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
cancel()
|
||||
return result, fmt.Errorf("build webhook request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("X-Webhook-Event", fmt.Sprintf("automation_event.%s", req.EventName))
|
||||
|
||||
resp, err := d.client.Do(httpReq)
|
||||
cancel()
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("send webhook: %w", err)
|
||||
result.Retryable = true
|
||||
} else {
|
||||
result.ResponseCode = resp.StatusCode
|
||||
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
result.ResponseBody = string(responseBody)
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
result.Retryable = false
|
||||
return result, nil
|
||||
}
|
||||
result.Retryable = resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests
|
||||
lastErr = fmt.Errorf("webhook returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if !result.Retryable || attempt == d.maxAttempts {
|
||||
break
|
||||
}
|
||||
if d.retryDelay > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return result, ctx.Err()
|
||||
case <-time.After(d.retryDelay):
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, lastErr
|
||||
}
|
||||
|
||||
type SMTPAutomationTranscriptDeliverer struct {
|
||||
Address string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
maxAttempts int
|
||||
retryDelay time.Duration
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewEnvAutomationTranscriptDeliverer() *SMTPAutomationTranscriptDeliverer {
|
||||
return &SMTPAutomationTranscriptDeliverer{
|
||||
Address: strings.TrimSpace(os.Getenv("SMTP_ADDRESS")),
|
||||
Port: actionEnvInt("SMTP_PORT", 587),
|
||||
Username: firstActionEnv("SMTP_USERNAME", "SMTP_LOGIN"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
From: firstActionEnv("MAILER_SENDER_EMAIL", "SMTP_FROM"),
|
||||
maxAttempts: defaultActionDeliveryAttempts,
|
||||
timeout: defaultActionDeliveryTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SMTPAutomationTranscriptDeliverer) DeliverTranscript(ctx context.Context, req AutomationTranscriptRequest) (ActionDeliveryResult, error) {
|
||||
result := ActionDeliveryResult{DeliveryType: "email_transcript", Target: req.Recipient}
|
||||
if strings.TrimSpace(req.Recipient) == "" {
|
||||
return result, errors.New("recipient email is required")
|
||||
}
|
||||
if d == nil || strings.TrimSpace(d.Address) == "" {
|
||||
result.Attempts = 1
|
||||
result.ResponseBody = "smtp_not_configured"
|
||||
return result, nil
|
||||
}
|
||||
maxAttempts := d.maxAttempts
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = defaultActionDeliveryAttempts
|
||||
}
|
||||
timeout := d.timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultActionDeliveryTimeout
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
result.Attempts = attempt
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
lastErr = d.send(attemptCtx, req)
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
result.Retryable = false
|
||||
return result, nil
|
||||
}
|
||||
result.Retryable = true
|
||||
result.ResponseBody = lastErr.Error()
|
||||
if attempt == maxAttempts {
|
||||
break
|
||||
}
|
||||
if d.retryDelay > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return result, ctx.Err()
|
||||
case <-time.After(d.retryDelay):
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, lastErr
|
||||
}
|
||||
|
||||
func (d *SMTPAutomationTranscriptDeliverer) send(ctx context.Context, req AutomationTranscriptRequest) error {
|
||||
fromHeader := strings.TrimSpace(d.From)
|
||||
if fromHeader == "" {
|
||||
fromHeader = "Chatwoot <accounts@chatwoot.com>"
|
||||
}
|
||||
fromAddress := fromHeader
|
||||
if parsed, err := mail.ParseAddress(fromHeader); err == nil {
|
||||
fromAddress = parsed.Address
|
||||
}
|
||||
message := smtpTranscriptMessage(fromHeader, req.Recipient, req.Subject, req.Body)
|
||||
addr := fmt.Sprintf("%s:%d", strings.TrimSpace(d.Address), d.Port)
|
||||
|
||||
var auth smtp.Auth
|
||||
if strings.TrimSpace(d.Username) != "" {
|
||||
auth = smtp.PlainAuth("", strings.TrimSpace(d.Username), d.Password, strings.TrimSpace(d.Address))
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- smtp.SendMail(addr, auth, fromAddress, []string{req.Recipient}, []byte(message))
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-done:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func smtpTranscriptMessage(from, to, subject, body string) string {
|
||||
return strings.Join([]string{
|
||||
"From: " + from,
|
||||
"To: " + to,
|
||||
"Subject: " + subject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
body,
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
func firstActionEnv(keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func actionEnvInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
@@ -24,19 +26,52 @@ const (
|
||||
// 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
|
||||
db DBProvider
|
||||
webhookDeliverer AutomationWebhookDeliverer
|
||||
transcriptDeliverer AutomationTranscriptDeliverer
|
||||
}
|
||||
|
||||
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}
|
||||
return &ActionService{
|
||||
db: db,
|
||||
webhookDeliverer: defaultWebhookDelivererFactory(),
|
||||
transcriptDeliverer: defaultTranscriptDelivererFactory(),
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -46,59 +81,71 @@ func (s *ActionService) Execute(ctx context.Context, accountID uint, conversatio
|
||||
// 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":
|
||||
return s.handleSendMessage(ctx, accountID, conversationID, resolvedAction, source, sourceID)
|
||||
err = s.handleSendMessage(ctx, accountID, conversationID, resolvedAction, source, sourceID)
|
||||
case "add_label":
|
||||
return s.handleAddLabel(ctx, accountID, conversationID, resolvedAction)
|
||||
err = s.handleAddLabel(ctx, accountID, conversationID, resolvedAction)
|
||||
case "remove_label":
|
||||
return s.handleRemoveLabel(ctx, accountID, conversationID, resolvedAction)
|
||||
err = s.handleRemoveLabel(ctx, accountID, conversationID, resolvedAction)
|
||||
case "assign_agent":
|
||||
return s.handleAssignAgent(ctx, accountID, conversationID, resolvedAction)
|
||||
err = s.handleAssignAgent(ctx, accountID, conversationID, resolvedAction)
|
||||
case "assign_team":
|
||||
return s.handleAssignTeam(ctx, accountID, conversationID, resolvedAction)
|
||||
err = s.handleAssignTeam(ctx, accountID, conversationID, resolvedAction)
|
||||
case "remove_assigned_agent":
|
||||
return s.handleRemoveAssignedAgent(ctx, accountID, conversationID)
|
||||
err = s.handleRemoveAssignedAgent(ctx, accountID, conversationID)
|
||||
case "remove_assigned_team":
|
||||
return s.handleRemoveAssignedTeam(ctx, accountID, conversationID)
|
||||
err = s.handleRemoveAssignedTeam(ctx, accountID, conversationID)
|
||||
case "send_webhook_event":
|
||||
return s.handleSendWebhookEvent(ctx, accountID, conversationID, resolvedAction)
|
||||
deliveryResult, err = s.handleSendWebhookEvent(ctx, accountID, conversationID, resolvedAction)
|
||||
case "mute_conversation":
|
||||
return s.handleMuteConversation(ctx, accountID, conversationID)
|
||||
err = s.handleMuteConversation(ctx, accountID, conversationID)
|
||||
case "change_status":
|
||||
return s.handleChangeStatus(ctx, accountID, conversationID, resolvedAction)
|
||||
err = s.handleChangeStatus(ctx, accountID, conversationID, resolvedAction)
|
||||
case "resolve_conversation":
|
||||
return s.handleChangeStatus(ctx, accountID, conversationID, Action{
|
||||
ActionName: "change_status",
|
||||
err = 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",
|
||||
err = 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",
|
||||
err = 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",
|
||||
err = 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)
|
||||
err = s.handleChangePriority(ctx, accountID, conversationID, resolvedAction)
|
||||
case "send_email_transcript":
|
||||
return s.handleSendEmailTranscript(ctx, accountID, conversationID, resolvedAction)
|
||||
deliveryResult, err = s.handleSendEmailTranscript(ctx, accountID, conversationID, resolvedAction)
|
||||
case "send_attachment":
|
||||
return s.handleSendAttachment(ctx, accountID, conversationID, resolvedAction)
|
||||
err = s.handleSendAttachment(ctx, accountID, conversationID, resolvedAction)
|
||||
case "add_private_note":
|
||||
return s.handleAddPrivateNote(ctx, accountID, conversationID, resolvedAction, source, sourceID)
|
||||
err = s.handleAddPrivateNote(ctx, accountID, conversationID, resolvedAction, source, sourceID)
|
||||
default:
|
||||
return fmt.Errorf("unsupported action: %s", resolvedAction.ActionName)
|
||||
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
|
||||
@@ -112,7 +159,7 @@ func (s *ActionService) resolveTemplateVars(ctx context.Context, conversationID
|
||||
|
||||
resolvedParams := ResolveActionParams(action.ActionParams, tctx)
|
||||
return Action{
|
||||
ActionName: action.ActionName,
|
||||
ActionName: action.ActionName,
|
||||
ActionParams: resolvedParams,
|
||||
}, nil
|
||||
}
|
||||
@@ -246,17 +293,30 @@ func (s *ActionService) handleRemoveAssignedTeam(ctx context.Context, accountID,
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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 == "" {
|
||||
return fmt.Errorf("send_webhook_event action requires 'url' param")
|
||||
values := extractStringSlice(action.ActionParams, "values")
|
||||
if len(values) > 0 {
|
||||
url = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
return s.webhookDeliverer.DeliverWebhook(ctx, AutomationWebhookRequest{
|
||||
AccountID: accountID,
|
||||
ConversationID: conversationID,
|
||||
EventName: eventName,
|
||||
URL: url,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
// handleMuteConversation mutes notifications for the conversation.
|
||||
@@ -324,16 +384,36 @@ func (s *ActionService) handleChangePriority(ctx context.Context, accountID, con
|
||||
}
|
||||
|
||||
// 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")
|
||||
// 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")
|
||||
}
|
||||
|
||||
// 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
|
||||
subject, body, err := s.buildTranscriptEmail(ctx, accountID, conversationID)
|
||||
if err != nil {
|
||||
return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}, err
|
||||
}
|
||||
|
||||
aggregate := ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}
|
||||
for _, recipient := range recipients {
|
||||
result, err := s.transcriptDeliverer.DeliverTranscript(ctx, AutomationTranscriptRequest{
|
||||
AccountID: accountID,
|
||||
ConversationID: conversationID,
|
||||
Recipient: recipient,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
})
|
||||
aggregate.Attempts += result.Attempts
|
||||
aggregate.ResponseCode = result.ResponseCode
|
||||
aggregate.ResponseBody = result.ResponseBody
|
||||
aggregate.Retryable = result.Retryable
|
||||
if err != nil {
|
||||
return aggregate, err
|
||||
}
|
||||
}
|
||||
return aggregate, nil
|
||||
}
|
||||
|
||||
// handleSendAttachment sends an attachment message to the conversation.
|
||||
@@ -456,6 +536,206 @@ func extractUintParam(params map[string]interface{}, key string) uint {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func firstStringParam(params map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if raw, ok := params[key]; ok {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
case []string:
|
||||
if len(v) > 0 {
|
||||
if trimmed := strings.TrimSpace(v[0]); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
if len(v) > 0 {
|
||||
if trimmed := strings.TrimSpace(fmt.Sprintf("%v", v[0])); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractTranscriptRecipients(params map[string]interface{}) []string {
|
||||
var raw []string
|
||||
if email := firstStringParam(params, "email", "emails"); email != "" {
|
||||
raw = append(raw, email)
|
||||
}
|
||||
raw = append(raw, extractStringSlice(params, "values")...)
|
||||
seen := map[string]bool{}
|
||||
recipients := make([]string, 0, len(raw))
|
||||
for _, value := range raw {
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
recipient := strings.TrimSpace(item)
|
||||
if recipient == "" || seen[recipient] {
|
||||
continue
|
||||
}
|
||||
seen[recipient] = true
|
||||
recipients = append(recipients, recipient)
|
||||
}
|
||||
}
|
||||
return recipients
|
||||
}
|
||||
|
||||
func (s *ActionService) buildAutomationWebhookPayload(ctx context.Context, accountID, conversationID uint, eventName string) (map[string]interface{}, error) {
|
||||
var conversation model.Conversation
|
||||
if err := s.db.DB().WithContext(ctx).
|
||||
Where("id = ? AND account_id = ?", conversationID, accountID).
|
||||
First(&conversation).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var messages []model.Message
|
||||
_ = s.db.DB().WithContext(ctx).
|
||||
Where("conversation_id = ? AND account_id = ?", conversationID, accountID).
|
||||
Order("id DESC").
|
||||
Limit(1).
|
||||
Find(&messages).Error
|
||||
|
||||
messagePayloads := make([]map[string]interface{}, 0, len(messages))
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
messagePayloads = append(messagePayloads, automationMessageWebhookPayload(messages[i]))
|
||||
}
|
||||
|
||||
displayID := conversation.ID
|
||||
if conversation.DisplayID != nil && *conversation.DisplayID > 0 {
|
||||
displayID = *conversation.DisplayID
|
||||
}
|
||||
createdAt := conversation.CreatedAt.Unix()
|
||||
updatedAt := float64(conversation.UpdatedAt.UnixNano()) / float64(time.Second)
|
||||
payload := map[string]interface{}{
|
||||
"event": fmt.Sprintf("automation_event.%s", eventName),
|
||||
"additional_attributes": jsonObject(conversation.AdditionalAttributes),
|
||||
"custom_attributes": jsonObject(conversation.CustomAttributes),
|
||||
"id": displayID,
|
||||
"conversation_id": conversation.ID,
|
||||
"uuid": conversation.UUID,
|
||||
"account_id": conversation.AccountID,
|
||||
"inbox_id": conversation.InboxID,
|
||||
"contact_id": conversation.ContactID,
|
||||
"status": conversation.Status,
|
||||
"priority": conversation.Priority,
|
||||
"channel": conversation.ChannelType,
|
||||
"messages": messagePayloads,
|
||||
"labels": splitConversationLabels(conversation.Labels),
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
}
|
||||
if conversation.AssigneeID != nil {
|
||||
payload["assignee_id"] = *conversation.AssigneeID
|
||||
}
|
||||
if conversation.TeamID != nil {
|
||||
payload["team_id"] = *conversation.TeamID
|
||||
}
|
||||
if conversation.LastActivityAt != nil {
|
||||
payload["last_activity_at"] = *conversation.LastActivityAt
|
||||
payload["timestamp"] = *conversation.LastActivityAt
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func automationMessageWebhookPayload(message model.Message) map[string]interface{} {
|
||||
payload := map[string]interface{}{
|
||||
"id": message.ID,
|
||||
"content": message.Content,
|
||||
"message_type": message.MessageType,
|
||||
"content_type": message.ContentType,
|
||||
"private": message.Private,
|
||||
"conversation_id": message.ConversationID,
|
||||
"account_id": message.AccountID,
|
||||
"source_id": message.SourceID,
|
||||
"created_at": message.CreatedAt.Unix(),
|
||||
"updated_at": float64(message.UpdatedAt.UnixNano()) / float64(time.Second),
|
||||
"additional_attributes": jsonObject(message.AdditionalAttributes),
|
||||
}
|
||||
if message.SenderID != nil {
|
||||
payload["sender_id"] = *message.SenderID
|
||||
}
|
||||
if message.SenderType != "" {
|
||||
payload["sender_type"] = message.SenderType
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func (s *ActionService) buildTranscriptEmail(ctx context.Context, accountID, conversationID uint) (string, string, error) {
|
||||
var conversation model.Conversation
|
||||
if err := s.db.DB().WithContext(ctx).
|
||||
Where("id = ? AND account_id = ?", conversationID, accountID).
|
||||
First(&conversation).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var messages []model.Message
|
||||
if err := s.db.DB().WithContext(ctx).
|
||||
Where("conversation_id = ? AND account_id = ? AND private = ?", conversationID, accountID, false).
|
||||
Order("id ASC").
|
||||
Find(&messages).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
displayID := conversation.ID
|
||||
if conversation.DisplayID != nil && *conversation.DisplayID > 0 {
|
||||
displayID = *conversation.DisplayID
|
||||
}
|
||||
subject := fmt.Sprintf("[#%d] Conversation Transcript", displayID)
|
||||
var body strings.Builder
|
||||
body.WriteString(fmt.Sprintf("Conversation #%d transcript\n\n", displayID))
|
||||
for _, message := range messages {
|
||||
if strings.TrimSpace(message.Content) == "" {
|
||||
continue
|
||||
}
|
||||
body.WriteString(fmt.Sprintf("[%s] %s\n", message.MessageType, message.Content))
|
||||
}
|
||||
return subject, body.String(), nil
|
||||
}
|
||||
|
||||
func 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"]
|
||||
@@ -472,4 +752,4 @@ func sourceID(action Action) uint {
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestHTTPAutomationWebhookDeliverer_RetriesRetryableResponses(t *testing.T) {
|
||||
attempts := 0
|
||||
client := &http.Client{
|
||||
Timeout: time.Second,
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
if req.Header.Get("X-Webhook-Event") != "automation_event.conversation_created" {
|
||||
t.Fatalf("unexpected webhook event header: %s", req.Header.Get("X-Webhook-Event"))
|
||||
}
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
if !strings.Contains(string(body), "conversation_id") {
|
||||
t.Fatalf("expected webhook body to include conversation_id, got %s", string(body))
|
||||
}
|
||||
if attempts < 3 {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader("retry me")), Header: make(http.Header)}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
deliverer := NewHTTPAutomationWebhookDeliverer(client, 3, 0)
|
||||
result, err := deliverer.DeliverWebhook(context.Background(), AutomationWebhookRequest{
|
||||
EventName: "conversation_created",
|
||||
URL: "https://example.test/webhook",
|
||||
Payload: map[string]interface{}{"conversation_id": float64(42)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected retry-to-success webhook delivery, got: %v", err)
|
||||
}
|
||||
if attempts != 3 || result.Attempts != 3 || result.ResponseCode != http.StatusNoContent {
|
||||
t.Fatalf("unexpected retry result: attempts=%d result=%#v", attempts, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationRuleService_MatchAndExecute_RecordsWebhookDeliveryMetadata(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
db := dbProvider.DB()
|
||||
accountID, _ := seedTestAccount(db, t)
|
||||
inboxID := seedTestInbox(db, t, accountID)
|
||||
contactID := seedTestContact(db, t, accountID)
|
||||
conversationID := seedTestConversation(db, t, accountID, inboxID, contactID)
|
||||
restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{
|
||||
result: ActionDeliveryResult{DeliveryType: "webhook", Target: "https://hooks.example/automation", Attempts: 2, ResponseCode: http.StatusOK},
|
||||
}, &recordingTranscriptDeliverer{})
|
||||
defer restore()
|
||||
|
||||
if err := db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "hello", ContentType: "text", MessageType: "incoming"}).Error; err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
|
||||
svc := NewAutomationRuleService(dbProvider)
|
||||
rule := &AutomationRule{
|
||||
AccountID: accountID,
|
||||
EventName: "conversation_created",
|
||||
Name: "webhook delivery",
|
||||
Conditions: Conditions{},
|
||||
Actions: Actions{{ActionName: "send_webhook_event", ActionParams: map[string]interface{}{
|
||||
"url": "https://hooks.example/automation",
|
||||
}}},
|
||||
Active: true,
|
||||
}
|
||||
if err := svc.Create(context.Background(), rule); err != nil {
|
||||
t.Fatalf("create rule: %v", err)
|
||||
}
|
||||
if err := svc.MatchAndExecute(context.Background(), accountID, "conversation_created", conversationID, map[string]interface{}{}); err != nil {
|
||||
t.Fatalf("match and execute: %v", err)
|
||||
}
|
||||
|
||||
logs, err := NewExecutionLogService(dbProvider).ListRuleExecutions(context.Background(), accountID, rule.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list executions: %v", err)
|
||||
}
|
||||
if len(logs) != 1 || logs[0].Status != ExecutionStatusSuccess {
|
||||
t.Fatalf("expected one successful execution log, got %#v", logs)
|
||||
}
|
||||
var results []ActionExecutionResult
|
||||
if err := json.Unmarshal(logs[0].ActionResults, &results); err != nil {
|
||||
t.Fatalf("unmarshal action results: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected one action result, got %d", len(results))
|
||||
}
|
||||
result := results[0]
|
||||
if result.DeliveryType != "webhook" || result.Target != "https://hooks.example/automation" || result.Attempts != 2 || result.ResponseCode != http.StatusOK {
|
||||
t.Fatalf("unexpected webhook action metadata: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionService_SendEmailTranscript_DeliversSplitRecipients(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
db := dbProvider.DB()
|
||||
accountID, _ := seedTestAccount(db, t)
|
||||
inboxID := seedTestInbox(db, t, accountID)
|
||||
contactID := seedTestContact(db, t, accountID)
|
||||
conversationID := seedTestConversation(db, t, accountID, inboxID, contactID)
|
||||
transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "email_transcript", Attempts: 1}}
|
||||
restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript)
|
||||
defer restore()
|
||||
|
||||
if err := db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "transcript body", ContentType: "text", MessageType: "incoming"}).Error; err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
|
||||
result, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{
|
||||
ActionName: "send_email_transcript",
|
||||
ActionParams: map[string]interface{}{"email": "first@example.com, second@example.com"},
|
||||
}, ActionSourceAutomation, 99)
|
||||
if err != nil {
|
||||
t.Fatalf("expected transcript action success, got: %v", err)
|
||||
}
|
||||
if len(transcript.requests) != 2 {
|
||||
t.Fatalf("expected two transcript deliveries, got %d", len(transcript.requests))
|
||||
}
|
||||
if transcript.requests[0].Recipient != "first@example.com" || transcript.requests[1].Recipient != "second@example.com" {
|
||||
t.Fatalf("unexpected transcript recipients: %#v", transcript.requests)
|
||||
}
|
||||
if !strings.Contains(transcript.requests[0].Subject, "Conversation Transcript") || !strings.Contains(transcript.requests[0].Body, "transcript body") {
|
||||
t.Fatalf("expected transcript subject/body to be populated: %#v", transcript.requests[0])
|
||||
}
|
||||
if result.DeliveryType != "email_transcript" || result.Target != "first@example.com,second@example.com" || result.Attempts != 2 {
|
||||
t.Fatalf("unexpected transcript action result metadata: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationRuleService_MatchAndExecute_RecordsEmailTranscriptFailureMetadata(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
db := dbProvider.DB()
|
||||
accountID, _ := seedTestAccount(db, t)
|
||||
inboxID := seedTestInbox(db, t, accountID)
|
||||
contactID := seedTestContact(db, t, accountID)
|
||||
conversationID := seedTestConversation(db, t, accountID, inboxID, contactID)
|
||||
restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, &recordingTranscriptDeliverer{
|
||||
result: ActionDeliveryResult{DeliveryType: "email_transcript", Target: "agent@example.com", Attempts: 3, Retryable: true},
|
||||
err: errors.New("smtp failed after retries"),
|
||||
})
|
||||
defer restore()
|
||||
|
||||
svc := NewAutomationRuleService(dbProvider)
|
||||
rule := &AutomationRule{
|
||||
AccountID: accountID,
|
||||
EventName: "conversation_resolved",
|
||||
Name: "transcript delivery",
|
||||
Conditions: Conditions{},
|
||||
Actions: Actions{{ActionName: "send_email_transcript", ActionParams: map[string]interface{}{"email": "agent@example.com"}}},
|
||||
Active: true,
|
||||
}
|
||||
if err := svc.Create(context.Background(), rule); err != nil {
|
||||
t.Fatalf("create rule: %v", err)
|
||||
}
|
||||
if err := svc.MatchAndExecute(context.Background(), accountID, "conversation_resolved", conversationID, map[string]interface{}{}); err != nil {
|
||||
t.Fatalf("match and execute: %v", err)
|
||||
}
|
||||
|
||||
logs, err := NewExecutionLogService(dbProvider).ListRuleExecutions(context.Background(), accountID, rule.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list executions: %v", err)
|
||||
}
|
||||
if len(logs) != 1 || logs[0].Status != ExecutionStatusFailed || logs[0].ActionsFailed != 1 {
|
||||
t.Fatalf("expected failed transcript execution log, got %#v", logs)
|
||||
}
|
||||
var results []ActionExecutionResult
|
||||
if err := json.Unmarshal(logs[0].ActionResults, &results); err != nil {
|
||||
t.Fatalf("unmarshal action results: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Attempts != 3 || !results[0].Retryable || results[0].Error == "" {
|
||||
t.Fatalf("unexpected failed transcript metadata: %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingWebhookDeliverer struct {
|
||||
requests []AutomationWebhookRequest
|
||||
result ActionDeliveryResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *recordingWebhookDeliverer) DeliverWebhook(ctx context.Context, req AutomationWebhookRequest) (ActionDeliveryResult, error) {
|
||||
d.requests = append(d.requests, req)
|
||||
result := d.result
|
||||
if result.DeliveryType == "" {
|
||||
result.DeliveryType = "webhook"
|
||||
}
|
||||
if result.Target == "" {
|
||||
result.Target = req.URL
|
||||
}
|
||||
return result, d.err
|
||||
}
|
||||
|
||||
type recordingTranscriptDeliverer struct {
|
||||
requests []AutomationTranscriptRequest
|
||||
result ActionDeliveryResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *recordingTranscriptDeliverer) DeliverTranscript(ctx context.Context, req AutomationTranscriptRequest) (ActionDeliveryResult, error) {
|
||||
d.requests = append(d.requests, req)
|
||||
result := d.result
|
||||
if result.DeliveryType == "" {
|
||||
result.DeliveryType = "email_transcript"
|
||||
}
|
||||
if result.Target == "" {
|
||||
result.Target = req.Recipient
|
||||
}
|
||||
return result, d.err
|
||||
}
|
||||
@@ -20,9 +20,15 @@ type ExecutionLogService struct {
|
||||
|
||||
// ActionExecutionResult records the result of one action within a rule execution.
|
||||
type ActionExecutionResult struct {
|
||||
ActionName string `json:"action_name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ActionName string `json:"action_name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DeliveryType string `json:"delivery_type,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Attempts int `json:"attempts,omitempty"`
|
||||
ResponseCode int `json:"response_code,omitempty"`
|
||||
ResponseBody string `json:"response_body,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
// NewExecutionLogService creates a new ExecutionLogService.
|
||||
|
||||
@@ -262,17 +262,19 @@ func (s *AutomationRuleService) MatchAndExecute(ctx context.Context, accountID u
|
||||
var firstErrorMsg string
|
||||
actionResults := make([]ActionExecutionResult, 0, len(rule.Actions))
|
||||
for _, action := range rule.Actions {
|
||||
if err := actionSvc.Execute(ctx, accountID, conversationID, action, ActionSourceAutomation, rule.ID); err != nil {
|
||||
actionForExecution := actionWithAutomationContext(action, eventName)
|
||||
result, err := actionSvc.ExecuteWithResult(ctx, accountID, conversationID, actionForExecution, ActionSourceAutomation, rule.ID)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("action %s failed for rule %d on conversation %d: %v", action.ActionName, rule.ID, conversationID, err)
|
||||
actionsFailed++
|
||||
if firstErrorMsg == "" {
|
||||
firstErrorMsg = err.Error()
|
||||
}
|
||||
actionResults = append(actionResults, ActionExecutionResult{ActionName: action.ActionName, Status: ExecutionStatusFailed, Error: err.Error()})
|
||||
actionResults = append(actionResults, result)
|
||||
// Continue executing remaining actions (Chatwoot pattern)
|
||||
} else {
|
||||
actionsExecuted++
|
||||
actionResults = append(actionResults, ActionExecutionResult{ActionName: action.ActionName, Status: ExecutionStatusSuccess})
|
||||
actionResults = append(actionResults, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +293,15 @@ func (s *AutomationRuleService) MatchAndExecute(ctx context.Context, accountID u
|
||||
return nil
|
||||
}
|
||||
|
||||
func actionWithAutomationContext(action Action, eventName string) Action {
|
||||
params := map[string]interface{}{}
|
||||
for key, value := range action.ActionParams {
|
||||
params[key] = value
|
||||
}
|
||||
params["_event_name"] = eventName
|
||||
return Action{ActionName: action.ActionName, ActionParams: params}
|
||||
}
|
||||
|
||||
// loadConversation loads a conversation from the database.
|
||||
func (s *AutomationRuleService) loadConversation(ctx context.Context, conversationID uint) (*ConversationForFilter, error) {
|
||||
var conv ConversationForFilter
|
||||
|
||||
@@ -395,6 +395,8 @@ func actionArrayToMap(actionName string, values []interface{}) map[string]interf
|
||||
params["content"] = first
|
||||
case "send_email_to_contact", "send_email_transcript":
|
||||
params["email"] = first
|
||||
case "send_webhook_event":
|
||||
params["url"] = first
|
||||
case "send_attachment":
|
||||
params["blob_id"] = first
|
||||
case "add_sla":
|
||||
@@ -428,6 +430,8 @@ func chatwootActionParams(action automation.Action) []interface{} {
|
||||
return compactValues(params["content"], params["message"])
|
||||
case "send_email_to_contact", "send_email_transcript":
|
||||
return compactValues(params["email"])
|
||||
case "send_webhook_event":
|
||||
return compactValues(params["url"], params["webhook_url"])
|
||||
case "send_attachment":
|
||||
return compactValues(params["blob_id"], params["attachment_url"])
|
||||
case "add_sla":
|
||||
|
||||
Reference in New Issue
Block a user