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