diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 9b9782ee..27cb34d8 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -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.` 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.`, 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. diff --git a/internal/automation/action_delivery.go b/internal/automation/action_delivery.go new file mode 100644 index 00000000..825845d6 --- /dev/null +++ b/internal/automation/action_delivery.go @@ -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 " + } + 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 +} diff --git a/internal/automation/action_service.go b/internal/automation/action_service.go index a9ad0bfb..35ea1c2f 100644 --- a/internal/automation/action_service.go +++ b/internal/automation/action_service.go @@ -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 } -} \ No newline at end of file +} diff --git a/internal/automation/action_service_test.go b/internal/automation/action_service_test.go new file mode 100644 index 00000000..83df13f6 --- /dev/null +++ b/internal/automation/action_service_test.go @@ -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 +} diff --git a/internal/automation/execution_log_service.go b/internal/automation/execution_log_service.go index 0b50bb52..b6265351 100644 --- a/internal/automation/execution_log_service.go +++ b/internal/automation/execution_log_service.go @@ -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. diff --git a/internal/automation/service.go b/internal/automation/service.go index a785e20f..987ae191 100644 --- a/internal/automation/service.go +++ b/internal/automation/service.go @@ -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 diff --git a/internal/handler/api/v1/automation_rule_handler.go b/internal/handler/api/v1/automation_rule_handler.go index 57cdc29c..ecb24f32 100644 --- a/internal/handler/api/v1/automation_rule_handler.go +++ b/internal/handler/api/v1/automation_rule_handler.go @@ -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":