feat(automation): deliver retryable external actions

This commit is contained in:
2026-06-05 09:54:50 +08:00
parent 72e980def9
commit 382933e601
7 changed files with 874 additions and 66 deletions
+263
View File
@@ -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
}
+324 -44
View File
@@ -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
}
}
}
+225
View File
@@ -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
}
+9 -3
View File
@@ -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.
+14 -3
View File
@@ -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":