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
+35 -16
View File
@@ -16,9 +16,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc
## Current Baseline
- Latest implementation checkpoint: `4e28559 feat(automation): record rule execution outcomes`.
- Latest documentation checkpoint: B9.1d delivery landing plan recorded in this tracker.
- Worktree status at this documentation checkpoint: B9.1c execution outcome logging is implemented and committed; next active slice is B9.1d retryable webhook/email transcript action delivery, followed by B9.2 macro execution parity.
- Latest implementation checkpoint: `feat(automation): deliver retryable external actions`.
- Latest documentation checkpoint: this checkpoint, recorded with the B9.1d implementation.
- Worktree status at this implementation checkpoint: B9.1d retryable webhook/email transcript delivery is implemented; next active slice is B9.2 macro CRUD and execution parity.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
@@ -44,11 +44,10 @@ Next ordered checkpoints:
| Order | Slice | Required outcome | Primary verification |
| --- | --- | --- | --- |
| 1 | B9.1d | Webhook and email transcript actions are timeout-bound, retryable, and observable through the worker/action boundary. | Fake HTTP/mailer tests and log/retry metadata assertions. |
| 2 | B9.2 | Macro CRUD and macro execute side effects match Chatwoot frontend expectations. | Macro handler/service tests that reload conversations/messages after execution. |
| 3 | B10 | Audit, CustomRole, and remaining InboxLimit surfaces enforce Chatwoot admin behavior. | Permission matrix tests, audit writer/list tests, inbox/account limit tests. |
| 4 | B11 | Captain/Copilot enterprise screens have real persistence and safe LLM feature gates. | Captain/Copilot handler/service fixtures and disabled-state tests. |
| 5 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. |
| 1 | B9.2 | Macro CRUD and macro execute side effects match Chatwoot frontend expectations. | Macro handler/service tests that reload conversations/messages after execution. |
| 2 | B10 | Audit, CustomRole, and remaining InboxLimit surfaces enforce Chatwoot admin behavior. | Permission matrix tests, audit writer/list tests, inbox/account limit tests. |
| 3 | B11 | Captain/Copilot enterprise screens have real persistence and safe LLM feature gates. | Captain/Copilot handler/service fixtures and disabled-state tests. |
| 4 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. |
## Execution Snapshot
@@ -140,12 +139,13 @@ This ledger records the committed parity checkpoints that future slices should b
| `feat(automation): align rule trigger coverage` | Completed B9.1b listener/event coverage: automation rules now honor Chatwoot skip rules for automation-origin events, auto-reply conversations, activity messages, and auto-reply emails; listener extraction works from event conversation ID, `conversation_id`, conversation payloads, and message payloads; conversation update/status/priority paths dispatch `changed_attributes`; initial conversation messages and provider webhook messages carry the message/conversation data needed by automation. | `go test ./internal/automation -run 'AutomationRuleListener\|MatchAndExecute\|Condition' -count=1`; `go test ./internal/service -run 'Conversation.*Automation\|Message.*Automation\|ConversationService' -count=1`; `go test ./internal/handler/webhook -run 'Incoming\|Webhook' -count=1`; `go test ./internal/automation -count=1`; `go test ./internal/service -count=1`; `go test ./internal/handler/webhook -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.1c execution logs and stop-on-match behavior, then B9.1d retryable webhook/email transcript actions and B9.2 macro parity. |
| `feat(automation): record rule execution outcomes` | Completed B9.1c observability/parity guard: rule evaluations now record skipped condition outcomes, condition failures, event names, aggregate action counts, and per-action success/failure results. Matching rules continue executing in ID order without stop-on-match, matching the current Chatwoot listener loop, and failed actions do not prevent later actions from running. | `go test ./internal/automation -run 'AutomationRuleService_MatchAndExecute\|ExecutionLogService' -count=1`; `go test ./internal/automation -count=1`; `go test ./internal/handler/webhook -run 'Incoming\|Webhook' -count=1`; `go test ./internal/service -run 'Conversation.*Automation\|Message.*Automation\|ConversationService' -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.1d retryable webhook/email transcript actions, then B9.2 macro payload/execute parity. |
| `docs: land automation delivery plan` | Recorded the complete B9.1d retryable external-action landing contract: webhook payload/delivery, transcript mailer boundary, retry/timeout behavior, action-result observability fields, B9.3 durable-worker deferral, and exit commands. It also refreshed stale B9 tracker entries that still pointed at B9.1b. | Documentation-only checkpoint; `git diff --check` before commit. | Start B9.1d implementation, then continue B9.2 macro CRUD/execute parity. |
| `feat(automation): deliver retryable external actions` | Completed B9.1d external action delivery: automation webhook actions now build Chatwoot-style `automation_event.<event>` payloads and deliver through a timeout-bound 3-attempt HTTP boundary; email transcript actions now split comma recipients, build transcript subject/body, deliver through a fakeable SMTP/mailer boundary, and both action types write delivery metadata into `action_results`. Chatwoot array params now round-trip `send_webhook_event` URLs. | `go test ./internal/automation -run 'ActionService\|AutomationRuleService_MatchAndExecute\|ExecutionLogService' -count=1`; `go test ./internal/automation -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.2 macro CRUD and macro execute side-effect parity; durable queued worker scheduling remains B9.3/Phase 5. |
## Next Slice Contract
Completed implementation slice: B5.1-B5.5 now cover inbox serializer shape, Chatwoot frontend create/update binding, working-hours persistence, out-of-office behavior, inbox member assignment payload/mutation semantics, channel-specific config depth, AgentCapacityPolicy/InboxCapacityLimit API data contracts, and dedicated Email/Twilio/LINE channel route response shapes. B6 is now in Review after `a16c23c` and `f08c743`: Chatwoot search response envelopes, frontend query params, Meilisearch sender filters, mocked hit serialization, env-gated live Meilisearch validation, release-mode DB fallback rejection, and reindex Meilisearch-only guard are covered. B7 is now in Review after `a98dc2c`, `95224fa`, `a11bb96`, `213bf2b`, `4f85ef1`, and `d23f3f0`, enforcing per-inbox capacity limits, aligning SLA policy CRUD payloads, persisting applied SLA records, making core FRT/NRT/RT breach events idempotent, aligning the applied-SLA reports API, and creating SLA miss notifications. B3 and B4 remain in review for deeper side effects and browser validation.
Next implementation slice: start B9.1d retryable webhook/email transcript action delivery now that B9.1a CRUD payloads, B9.1b listener coverage, and B9.1c execution outcome logs are committed.
Next implementation slice: start B9.2 macro CRUD and macro execution parity now that B9.1a CRUD payloads, B9.1b listener coverage, B9.1c execution outcome logs, and B9.1d retryable external actions are implemented.
| Step | Required result | Reference source | Verification |
| --- | --- | --- | --- |
@@ -162,7 +162,7 @@ Next implementation slice: start B9.1d retryable webhook/email transcript action
| N11 | Keep B9.1a automation-rule CRUD payload parity as current automation API baseline. | Chatwoot automation controller/Jbuilder views, dashboard automation API/store/helper files, current Go handler/service/validator. | Done by `feat(automation): align automation rule payloads`; frontend envelopes, `attribute_key`, operator aliases, array action params, account-scoped update/delete/clone, and empty delete are covered. |
| N12 | Keep B9.1b automation listener trigger coverage as current event baseline. | `AutomationRuleListener`, `BaseListener`, conversation/message service dispatch, provider webhook persister. | Done by `feat(automation): align rule trigger coverage`; reference listener events, skip rules, changed attributes, and provider message reachability are covered. |
| N13 | Keep B9.1c execution outcome logging as current automation observability baseline. | Chatwoot automation listener/action service rescue behavior and Go execution log service. | Done by `feat(automation): record rule execution outcomes`; skipped/failed/success action results, event names, and no-stop-on-match behavior are covered. |
| N14 | Implement B9.1d external action delivery boundary before macro expansion. | Chatwoot `AutomationRules::ActionService`, `WebhookJob`, transcript mailer path, current Go action service. | Webhook/email transcript actions must be timeout-bound, retryable, and observable through action result metadata with fake HTTP/mailer tests. |
| N14 | Keep B9.1d external action delivery boundary as current action-delivery baseline. | Chatwoot `AutomationRules::ActionService`, `WebhookJob`, transcript mailer path, current Go action service. | Done by `feat(automation): deliver retryable external actions`; webhook/email transcript actions are timeout-bound, retryable, observable in action results, and covered by fake HTTP/mailer tests. |
| N15 | Update this tracker after every implementation checkpoint. | This document. | `git diff --check`; `go test ./...` for Go changes. |
Current B2 profile checkpoint:
@@ -384,7 +384,7 @@ Upcoming enterprise task boards:
| B8 | B8.2 | Align public CSAT submit/update flow, lock window, message linkage, and one-response-per-message behavior. | `reference/chatwoot/app/controllers/public/api/v1/csat_survey_controller.rb`, CSAT response model/services. | Public handler tests for submit, update within window, update after lock, duplicate response rejection. | Done by `ef3a909` |
| B8 | B8.3 | Wire resolve-triggered CSAT survey send with idempotency and channel-aware message creation. | Chatwoot CSAT listener/jobs and inbox CSAT settings. | Listener tests prove one CSAT message per resolved conversation and no send when disabled. | Done by `ef3a909`; WhatsApp/Twilio external template dispatch remains integration follow-up |
| B8 | B8.4 | Align CSAT downloads/export payloads used by reports screens. | CSAT report/download controller paths and frontend report API. | CSV/download tests cover filters and frontend column names. | Done by `b36cf07` |
| B9 | B9.1 | Align automation rule CRUD payloads, validation, condition groups, event names, listener skip rules, event data, execution outcomes, and external action delivery. | Chatwoot automation rule controllers/models, `AutomationRuleListener`, action services/jobs, and dashboard automation builder. | Handler/service/listener tests for CRUD, validation, event mapping, skip rules, changed attributes, provider-dispatched events, execution logs, and retry metadata. | Doing; B9.1a done by `3403770`, B9.1b done by `feat(automation): align rule trigger coverage`, B9.1c done by `4e28559`, B9.1d is active next |
| B9 | B9.1 | Align automation rule CRUD payloads, validation, condition groups, event names, listener skip rules, event data, execution outcomes, and external action delivery. | Chatwoot automation rule controllers/models, `AutomationRuleListener`, action services/jobs, and dashboard automation builder. | Handler/service/listener tests for CRUD, validation, event mapping, skip rules, changed attributes, provider-dispatched events, execution logs, and retry metadata. | Review; B9.1a done by `3403770`, B9.1b done by `feat(automation): align rule trigger coverage`, B9.1c done by `4e28559`, B9.1d done by `feat(automation): deliver retryable external actions` |
| B9 | B9.2 | Implement automation action side effects for labels, status, priority, assignee/team, custom attributes, private notes, and snooze. | Chatwoot automation action services/listeners. | Rule listener tests assert real conversation mutations and stop-on-match behavior. | Todo |
| B9 | B9.3 | Add delayed action scheduling and durable worker parity after the B9.1d synchronous retry boundary lands. | Chatwoot automation jobs, delayed action handling, and Phase 5 worker plan. | Worker tests cover queued delayed actions, retries, and observable failures. | Todo |
| B9 | B9.4 | Align macro CRUD/availability and macro execution side effects. | Chatwoot macros controller/model/action execution. | Macro handler/service tests cover frontend payloads and conversation mutations. | Todo |
@@ -415,7 +415,7 @@ B9 automation and macro execution breakdown:
| B9.1a | Align automation-rule CRUD request/response envelopes, condition/action validation, and rule status toggles. | Chatwoot automation rules controller/model and dashboard automation client. | Handler tests for index/show/create/update/delete/toggle payloads and validation failures. | Done by `feat(automation): align automation rule payloads` |
| B9.1b | Implement real rule trigger coverage for Chatwoot automation events: conversation created/updated/opened/resolved and message created. Provider webhook persistence must dispatch into those same conversation/message events. `contact_updated` is not a current Chatwoot automation trigger and stays out unless the local reference/frontend proves otherwise. | `reference/chatwoot/app/listeners/automation_rule_listener.rb`, `reference/chatwoot/app/listeners/base_listener.rb`, existing `channel.Dispatcher` events. | Listener tests prove matching rules execute once per event with account/inbox filters, changed attributes, provider-event reachability, and Chatwoot skip rules. | Done by `feat(automation): align rule trigger coverage` |
| B9.1c | Persist execution logs and stop-on-match behavior, including failed action records. The current Chatwoot listener runs every matching rule, so Go explicitly preserves no-stop-on-match behavior and records it in tests. | `reference/chatwoot/app/listeners/automation_rule_listener.rb`, `reference/chatwoot/app/services/automation_rules/action_service.rb`, Go execution log service. | Service tests cover success, skipped, failed/partial, per-action result records, and all-matching-rules execution. | Done by `feat(automation): record rule execution outcomes` |
| B9.1d | Make webhook and email transcript actions timeout-bound, retryable, and observable. | Chatwoot action services/jobs and Phase 5 worker plan. | Tests use fake HTTP/mailer boundaries and assert retry/log metadata. | Active next |
| B9.1d | Make webhook and email transcript actions timeout-bound, retryable, and observable. | Chatwoot action services/jobs and Phase 5 worker plan. | Tests use fake HTTP/mailer boundaries and assert retry/log metadata. | Done by `feat(automation): deliver retryable external actions` |
| B9.2a | Align macro CRUD payloads, visibility rules, and validation. | Chatwoot macros controller/model and dashboard macro client. | Handler tests for raw macro payloads, ownership, account scoping, and delete status. | Todo |
| B9.2b | Execute macro actions against real conversations: labels, status, priority, assignee, team, custom attributes, private notes, and attachments where supported. | Chatwoot macro execution service and conversation/message controllers. | Macro execute tests reload conversation/message rows and assert Chatwoot-shaped response payloads. | Todo |
@@ -500,7 +500,7 @@ B9.1b current checkpoint:
- Listener context extraction now supports `event.ConversationID`, `event.Data["conversation_id"]`, `event.Data["conversation"]`, and `event.Data["message"]`, with account fallback from conversation/message payloads.
- Conversation update/status/priority paths now dispatch Chatwoot-style `changed_attributes` so `attribute_changed` automation conditions can match status, priority, and SLA policy changes.
- Conversation creation with an initial message now dispatches `message.created` with `ConversationID`, `ContactID`, message payload, and conversation payload; provider webhook persistence is covered by an automation regression proving incoming provider messages trigger `message_created` rules once.
- B9 remains `Doing` because B9.1c execution log/stop-on-match semantics, B9.1d retryable webhook/email transcript actions, and B9.2 macro parity are still active.
- B9 remains `Doing` because B9.2 macro parity and B9.3 delayed/durable worker parity are still active.
B9.1b verification:
@@ -522,7 +522,7 @@ B9.1c current checkpoint:
- Condition matcher errors are logged as failed rule evaluations instead of disappearing into process logs only.
- Failed actions record per-action errors while later actions still run, matching Chatwoot `AutomationRules::ActionService#perform` rescue-and-continue behavior.
- The current Chatwoot listener loops through every matching rule; Go preserves this no-stop-on-match behavior and has regression coverage proving two matching rules both execute.
- B9 remains `Doing` because B9.1d retryable webhook/email transcript actions and B9.2 macro parity are still active.
- B9 remains `Doing` because B9.2 macro parity and B9.3 delayed/durable worker parity are still active.
B9.1c verification:
@@ -562,6 +562,24 @@ env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./...
git diff --check
```
B9.1d current checkpoint:
- `send_webhook_event` is no longer a no-op. It accepts Chatwoot array params as `url`, builds a conversation webhook payload with `event: automation_event.<rule event>`, latest message data, conversation IDs/status/priority/attributes, and posts through a timeout-bound HTTP deliverer.
- The HTTP webhook deliverer retries retryable failures up to three attempts by default, treats `5xx` and `429` as retryable, records response code/body, and is injectable for tests.
- `send_email_transcript` is no longer a no-op. It parses comma-separated recipients, builds a Chatwoot-style `[#display_id] Conversation Transcript` subject plus transcript body, and delivers per recipient through a fakeable SMTP transcript boundary.
- `ActionService.ExecuteWithResult` preserves the existing `Execute` API while returning per-action metadata for automation execution logs.
- `AutomationExecution.action_results` now includes optional `delivery_type`, `target`, `attempts`, `response_code`, `response_body`, and `retryable` fields. Failed external actions still allow later actions/rules to continue under the B9.1c behavior.
- B9.1 moves to `Review`. Durable queued scheduling and delayed action parity remain B9.3/Phase 5; the next active implementation slice is B9.2 macro CRUD and execution parity.
B9.1d verification:
```bash
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/automation -run 'ActionService|AutomationRuleService_MatchAndExecute|ExecutionLogService' -count=1
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/automation -count=1
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./...
git diff --check
```
B10 admin and limits breakdown:
| Step | Implementation target | Reference source | Required tests | Status |
@@ -687,7 +705,7 @@ Remaining slice landing plan:
| B6 | Compare current Meilisearch document fields and global/entity search payloads against Chatwoot frontend consumers. | Add optional live Meilisearch integration gate, tighten account-scoped filters, and document DB fallback as development-only. | Done only after mocked tests and live-shape review prove search payloads work without DB-only assumptions. |
| B7 | Done: assignment capacity enforcement, SLA policy CRUD payload parity, applied-SLA conversation persistence, idempotent SLA breach lifecycle, applied-SLA report/list payload parity, and SLA miss notification fan-out. | Capacity-aware assignment policy binding review and durable SLA job scheduling remain broader Phase 5/P4.9 follow-ups, not hidden SLA gaps. | Review after `d23f3f0`; move to Done only after assignment policy binding review is either implemented or split out of B7. |
| B8 | Done: account-side CSAT list/metrics/review-note payload parity, public submit/update idempotency, resolve-triggered generic survey message send, and CSV download filters/columns. | Channel-specific WhatsApp/Twilio template send hooks and durable queueing are tracked under Phase 5. | Review after `b36cf07`; move to Done only after channel-specific send hooks are either implemented or formally split as external integration scope. |
| B9 | B9.1d retryable webhook/email transcript delivery, then B9.2 macro CRUD and macro execution side effects. | Automation rule condition/action parity, execution logs, webhook delivery retry, email transcript delivery, delayed actions, and macro action mutation parity. | Done only after Chatwoot listener events are covered, actions mutate real conversations, external actions are retryable/observable, and macro flows pass frontend payload tests. |
| B9 | B9.2 macro CRUD and macro execution side effects. | Macro payload parity, action mutation parity, delayed actions, and durable queued worker follow-ups. | Done only after Chatwoot listener events are covered, actions mutate real conversations, external actions are retryable/observable, macro flows pass frontend payload tests, and durable worker gaps are either implemented or explicitly split. |
| B10 | Audit list payload and audit writer boundary for representative mutating core resources. | CustomRole permission-key parity, AccountUser permission resolution, InboxLimit enforcement in inbox/channel creation paths. | Done only after authorization tests prove admin-only surfaces and non-admin denial shapes match Chatwoot. |
| B11 | Captain Assistant CRUD, inbox binding, responses, documents, and custom tools payload fixtures. | Copilot threads/messages/tasks, playground/tool-call behavior, document sync/embedding feature gates, streaming fallback. | Done only after LLM-dependent behavior is either implemented behind config or safely stubbed with frontend-compatible disabled states. |
| B12 | Boot reused Chatwoot frontend against GoChat auth/profile/inbox/conversation/contact flows. | Add smoke paths for widget init/message, public CSAT, reports, and enterprise screens as B7-B11 land. | Done only after the smoke command is repeatable and writes a checked gap report. |
@@ -979,7 +997,7 @@ Enterprise work package breakdown:
| SLA | Policy CRUD parity, conversation SLA assignment, first-response/next-response/resolution timers, business-hours handling, breach events, notifications. | Policy payloads, applied-SLA attach, seconds-based thresholds, core state transitions, breach idempotency, report payloads, reference `only_during_business_hours` timing, and notification fan-out are covered. Durable scheduling remains Phase 5. | Review |
| Assignment and capacity | Assignment policy CRUD, inbox policy binding, round-robin/availability/capacity selection, manual assignment limits, fallback behavior. | Manual and automatic assignment respect policy, availability, team/inbox membership, and limits. | Review |
| CSAT account/public side | Survey send on resolve, response list, metrics, filters, downloads, review notes, resend/idempotency, public lock. | Account report list/metrics/review-note payload fixtures are covered by `f441680`; public object-shaped submit/update, one-response-per-message idempotency, and resolve-triggered generic survey message send are covered by `ef3a909`; download CSV is covered by `b36cf07`. | Review |
| Automation rules | CRUD payload parity, condition/action parity, event listener coverage, delayed actions, execution logs, no-stop-on-match behavior, webhook and transcript actions. | B9.1a CRUD, B9.1b listener coverage, and B9.1c execution outcome logging are covered; next gate is B9.1d retryable webhook/email transcript delivery, followed by macro parity. | Doing |
| Automation rules | CRUD payload parity, condition/action parity, event listener coverage, delayed actions, execution logs, no-stop-on-match behavior, webhook and transcript actions. | B9.1a CRUD, B9.1b listener coverage, B9.1c execution outcome logging, and B9.1d retryable webhook/email transcript delivery are covered. Durable delayed actions remain B9.3/Phase 5. | Review |
| Macros | Macro CRUD, availability by account/user, execute side effects, validation, audit/log output. | Execute changes conversation labels/status/assignee/team/notes/custom attributes exactly as frontend expects. | Todo |
| Audit | Audit model parity, mutating action coverage, request metadata, filters/pagination, admin endpoint payloads. | Representative mutations across core and enterprise features emit audit records. | Todo |
| Custom roles | Permission-key parity, account-user role resolution, policy middleware, create/update/delete behavior. | Permission matrix tests and frontend admin payload fixtures. | Todo |
@@ -1210,3 +1228,4 @@ Verification milestone gates:
- 2026-06-05: B9.1b automation trigger checkpoint committed as `feat(automation): align rule trigger coverage`; automation listener now covers all current Chatwoot reference events, skips automation-origin/auto-reply/activity auto-reply-email cases, extracts account/conversation context from message and conversation event payloads, conversation update/status/priority paths dispatch `changed_attributes`, initial conversation messages dispatch full message-created context, and provider webhook persistence has a regression proving message-created automation reachability. Focused automation/service/webhook tests, package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B9.1c execution log/stop-on-match parity.
- 2026-06-05: B9.1c execution outcome checkpoint committed as `feat(automation): record rule execution outcomes`; automation evaluations now persist event names, skipped condition outcomes, condition errors, per-action success/failure JSON, and aggregate action counts. Failed actions continue to later actions, and every matching rule still executes in ID order to match the current Chatwoot listener's no-stop-on-match loop. Focused automation tests, automation package tests, relevant service/webhook regressions, full `go test ./...`, and `git diff --check` passed. Next slice is B9.1d retryable webhook/email transcript action delivery.
- 2026-06-05: B9.1d planning checkpoint prepared as `docs: land automation delivery plan`; the active tracker now points at B9.1d, records the Chatwoot webhook/transcript reference contracts, scopes the retryable/testable action delivery boundary, names B9.3 durable-worker follow-up work, and lists the required focused/full verification commands before macro work starts.
- 2026-06-05: B9.1d external action delivery checkpoint prepared as `feat(automation): deliver retryable external actions`; `send_webhook_event` now builds Chatwoot-style automation webhook payloads and delivers through a timeout-bound retryable HTTP boundary, `send_email_transcript` parses recipients and sends generated transcript emails through a fakeable SMTP boundary, and automation execution logs now record delivery type, target, attempts, response code/body, retryability, and failure errors. Focused automation tests, automation package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B9.2 macro CRUD and execution parity.
+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":