diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index e02c214b..a0dc3357 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,13 +49,13 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P3.107 Captain custom tool policy and auth serialization parity, prepared as `feat(captain): secure custom tool auth config`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(captain): secure custom tool auth config`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot-compatible Captain custom-tool policy gates and administrator-only auth-config serialization. +- Current tracking checkpoint: 2026-06-09 P3.166 agent bot message sendable parity, prepared as `fix(webhooks): align bot message sendable`. +- Latest implementation checkpoint: this checkpoint, prepared as `fix(webhooks): align bot message sendable`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot-compatible agent bot message sendable filtering that skips activity/template-exception messages for `message_created`/`message_updated`, matching Chatwoot `MessageFilterHelpers#webhook_sendable?`, while retaining P3.165 opened/resolved event names, P3.164 dispatcher registration, P3.163 assigned agent bot delivery, and P3.162 agent bot webhook request headers and P3.161 agent bot webhook payload shape, P3.160 agent bot webhook failure handling from top-level payload `id`, P3.159 `WEBHOOK_TIMEOUT` configuration support for account/API/agent-bot webhook clients, P3.158 5-second default timeout fallback, and P3.157 `keep_pending_on_bot_failure` behavior for agent bot webhook failures, P3.156 retryable agent bot webhook error handling that skips conversation reopen for 429/500 statuses, P3.155 agent bot webhook failure activity message parity, P3.147 inbox created/updated account webhook delivery, P3.146 contact created/updated account webhook delivery, P3.145 conversation typing webhook delivery, P3.144 account webhook delivery for subscribed message/conversation events, P3.143 API inbox webhook delivery, P3.142 API channel `reset_secret` behavior and P3.141 API channel webhook signing `secret` persistence, P3.140 API channel `agent_reply_time_window` validation, P3.139 API channel `additional_attributes` persistence, P3.138 API channel `hmac_mandatory`/identifier/HMAC-token persistence, P3.137 public API `hmac_mandatory` identity-validation behavior, P3.136 public HMAC-verified conversation scoping, P3.135 public conversation embedded-message attachment serialization, durable maintenance search-index clock preservation, P3.134 public message attachment creation/list serialization, P3.133 public message index latest/before windows, P3.132 public message content-length validation, P3.131 public conversation message visibility, P3.130 widget config invalid-token and suspended-account status behavior, P3.129 widget contact show/update/destroy-custom-attributes `404` behavior, P3.128 widget campaigns/events/inbox-members `404` and campaign feature-gate behavior, P3.127 widget label action `404` and undefined-label no-op behavior, P3.126 widget conversation helper `404` behavior, P3.125 widget incoming-message reopen behavior, P3.124 widget input-email contact identification, P3.123 widget message index finder behavior, P3.122 widget Dyte participant token scoping, P3.121 widget set-user HMAC gating, P3.120 macro webhook event naming, P3.119 widget message reply metadata/latest-conversation reuse, and P3.118 widget message content-length validation, P3.117 first-message widget conversation metadata behavior, P3.116 widget contact custom-attribute deletion routing, P3.115 widget transcript delivery and status behavior, P3.114 widget end-conversation gating, P3.113 widget conversation response semantics, P3.112 CSAT review-note update behavior, P3.111 date-range behavior, P3.110 CSV recorded-at formatting, P3.109 fixed report pagination, and P3.108 CSAT message-window send behavior. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: Captain custom-tool list/show/create/update now serialize `auth_config` only for administrator/super-admin request roles, while agent roles can list/show tools without secrets and receive raw `403 { error: "You are not authorized to do this action" }` for create/test/update/delete like `Captain::CustomToolPolicy`. This retains P3.106 custom-tool validation parity, P3.105 custom-tool create limits and slug parity, P3.104 Captain custom tool feature gating, P5.3i CSAT survey indexing, P5.3h provider webhook indexing, P5.3g message delivery indexing, P5.3f automation action indexing, P5.3e conversation maintenance indexing, P5.3d conversation bulk-action indexing, P5.3c contact label search indexing, P3.103 contact bulk-action parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: `AgentBotListener` now filters message events by message type before delivery, sending only `incoming`/`outgoing`/`template` messages to agent bots and skipping `activity`/`csat`/other non-sendable message types, matching Chatwoot `MessageFilterHelpers#webhook_sendable?`; `AgentBotListener` still maps `EventConversationOpened` to `conversation_opened` and `EventConversationResolved` to `conversation_resolved`, implements the channel dispatcher listener interface, and is registered in bootstrap so message/conversation/webwidget events actually reach agent bot webhook delivery; conversations still expose `assignee_agent_bot_id`, and agent bot webhook delivery includes the assigned agent bot alongside the active inbox bot, de-duplicating when they are the same bot, matching Chatwoot `AgentBotListener#agent_bots_for`; agent bot webhook delivery still sends Chatwoot-style headers: JSON `Accept`, per-delivery `X-Chatwoot-Delivery`, and `X-Chatwoot-Timestamp` plus `X-Chatwoot-Signature` over `timestamp.body` when the bot has a secret, removing the previous local `X-Signature`/`X-Agent-Bot-*` delivery headers; agent bot webhook delivery still sends event data as top-level JSON fields with `event`, `account_id`, and `inbox_id`, without the prior local `data`/`timestamp` wrapper, matching Chatwoot `AgentBotListener` payloads built from `message.webhook_data` / `conversation.webhook_data`; agent bot webhook failure handling still resolves the failed message from top-level payload `id` when conversation data is absent, then uses that message conversation for pending reopen / keep-pending behavior, matching Chatwoot `Webhooks::Trigger#message_id`, `#message`, and `#update_conversation_status`; outgoing account/API webhooks and agent bot webhooks still read positive integer `WEBHOOK_TIMEOUT` installation config values for HTTP client timeouts and fall back to the Chatwoot-style 5-second default for missing, blank, or invalid values, matching `Webhooks::Trigger#webhook_timeout`; accounts still expose `keep_pending_on_bot_failure`, and non-retryable agent bot webhook failures skip reopening pending conversations and skip bot-failure activity messages when that setting is enabled, matching Chatwoot account settings and `Webhooks::Trigger#update_conversation_status`; agent bot webhook failures for 429/500 statuses still skip conversation reopen to allow worker retry, matching Chatwoot `Webhooks::Trigger#retryable_agent_bot_error?`; non-retryable failures still reopen pending conversations AND create activity messages when the account setting is disabled, matching `#update_conversation_status` and `#create_agent_bot_error_activity`; `webwidget_triggered` events still flow from the widget service `TrackEvent` through the webhook listener to subscribed account webhooks with Chatwoot event name; conversation_updated webhook tests still verify nested custom_attributes changed-attribute formatting matches Chatwoot previous/current values; API inbox webhook failures for message_created/message_updated still mark the message status as `failed` in the database, matching Chatwoot `Webhooks::Trigger#update_message_status`; activity messages still skip webhook delivery matching Chatwoot `webhook_sendable?`; outgoing account/API inbox webhook requests now include Chatwoot-style `X-Chatwoot-Delivery` UUID headers; conversation opened/resolved events still flow as Chatwoot `conversation_status_changed` webhooks with previous/current status change payloads through both account and API inbox webhook paths; inbox created/updated events still flow to subscribed account webhooks, with inbox_updated skipped when changed attributes are blank and formatted as Chatwoot previous/current values when present; contact created/updated events still flow to subscribed account webhooks, with contact_updated skipped when changed attributes are blank and formatted as Chatwoot previous/current values when present; conversation typing_on/typing_off events still flow through the generic webhook listener to subscribed account webhooks and configured API inbox webhooks; account webhook subscriptions still receive subscribed message/conversation events as signed `account_webhook` POSTs, while API inbox message/conversation webhook events still POST Chatwoot-style payloads to `channel_api.webhook_url` as `api_inbox_webhook`, signed with `channel_api.secret`; API inbox reset_secret still regenerates the webhook signing `secret` on `channel_api` and the mirrored inbox secret while preserving the public HMAC token; API inbox create/update still persists the webhook signing `secret` on `channel_api`, validates `additional_attributes.agent_reply_time_window` as a positive integer, and syncs identifier, HMAC token, `hmac_mandatory`, webhook URL, and `additional_attributes` into the `channel_api` row used by public lookup/reset-secret paths, so public API inboxes continue to expose `identity_validation_enabled` from `channel_api.hmac_mandatory` and reject contact create/update without a valid `identifier_hash` when HMAC is mandatory, matching Chatwoot `process_hmac`; public HMAC-verified contacts still show and operate on conversations scoped to the contact across inbox contact-inbox records, while non-verified contacts remain scoped to their current contact inbox. Public inbox conversation index/show payloads still serialize attachments for embedded messages, and public message create/list still accepts staged widget upload signed IDs from multipart/JSON `attachments`, creates `Attachment` rows with Chatwoot-style file metadata, marks direct uploads completed, and serializes attachments on public message create/list payloads. Conversation maintenance search-index registration also preserves the worker clock so queued maintenance index jobs remain immediately processable in deterministic worker runs. This retains P3.133 public message finder-window parity, P3.132 public message content-length parity, P3.131 public conversation message visibility parity, P3.130 widget config status parity, P3.129 widget contact action status parity, P3.128 widget public-support endpoint parity, P3.127 widget label action status parity, P3.126 widget conversation helper status parity, P3.125 widget incoming reopen parity, P3.124 widget input-email parity, P3.123 widget message index parity, P3.122 widget Dyte participant parity, P3.121 widget set-user HMAC parity, P3.120 macro webhook event parity, P3.119 widget reply-to parity, P3.118 content-length parity, P3.117 first-message conversation metadata parity, P3.116 widget contact custom-attribute route parity, P3.115 widget transcript delivery parity, P3.114 widget end-conversation gate parity, P3.113 widget conversation response parity, P3.112 CSAT review-note update parity, P3.111 date-range behavior, P3.110 CSV recorded-at formatting, P3.109 fixed report pagination, P3.108 message-window send behavior, P3.107 Captain custom-tool policy/auth serialization parity, P3.106 custom-tool validation parity, P5.3i CSAT survey indexing, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes. -- `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full. +- `go test ./...` passes with workspace-local Go build/module/tmp caches for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full. - Route dump succeeds with `972` registered routes after enterprise account route tracking. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. - Tracked frontend-critical route audit covers 444 Chatwoot routes: 435 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. @@ -156,6 +156,41 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.142 API channel reset-secret parity | Implemented for dashboard/API inbox `reset_secret`: API channel reset now regenerates the Chatwoot `WebhookSecretable` webhook signing `secret` on `channel_api` and the mirrored inbox secret, while preserving the public HMAC token used for contact identity validation. | Keep in Review; reopen from B12 dashboard/API webhook smoke or fresh reference evidence for reset-secret response serialization or actual api_inbox_webhook delivery/signature drift. | Focused API reset-secret tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.141 API channel secret persistence parity | Implemented for dashboard/API inbox create/update: API channel webhook signing `secret` is now modeled and synced to `channel_api`, matching Chatwoot `WebhookSecretable` storage used by API inbox webhook delivery. | Keep in Review; reopen from B12 dashboard/API webhook smoke or fresh reference evidence for API secret serialization or actual api_inbox_webhook delivery/signature drift. | Focused API channel secret persistence tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.140 API channel reply-window validation parity | Implemented for dashboard/API inbox create/update: `additional_attributes.agent_reply_time_window` is now rejected unless it is a positive integer, matching Chatwoot `Channel::Api` validation before persisting the API channel row. | Keep in Review; reopen from B12 dashboard API smoke or fresh reference evidence for exact validation envelope text, API secret serialization, or reply-window runtime behavior drift. | Focused API reply-window validation tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.139 API channel additional attributes parity | Implemented for dashboard/API inbox create/update: API channel `additional_attributes` are now modeled and synced to `channel_api`, including Chatwoot-style values such as `agent_reply_time_window`, alongside identifier/HMAC/webhook fields. | Keep in Review; reopen from B12 public/dashboard API smoke or fresh reference evidence for `agent_reply_time_window` validation, API secret serialization, or additional-attribute serializer drift. | Focused API channel additional-attribute tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.138 API channel HMAC persistence parity | Implemented for dashboard/API inbox create/update: API inbox channel config now creates or updates the `channel_api` row with identifier, HMAC token, `hmac_mandatory`, webhook URL, and `ChannelID`, keeping public API lookup/reset-secret state in sync with inbox settings. | Keep in Review; reopen from B12 public/dashboard API smoke or fresh reference evidence for additional_attributes persistence, API secret serialization, or dashboard update edge cases. | Focused API channel persistence tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.137 public API HMAC mandatory parity | Implemented for reused public API contacts/inbox show: API channel `hmac_mandatory` is now modeled, `identity_validation_enabled` reflects mandatory validation rather than token presence, and contact create/update rejects missing or invalid identifier hashes when mandatory. | Keep in Review; reopen from B12 public API smoke or fresh reference evidence for exact error envelope/status drift, dashboard API-channel update persistence, or identifier_hash creation edge cases. | Focused public HMAC mandatory tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.136 public HMAC conversation scoping | Implemented for reused public inbox conversations: once a contact inbox is HMAC verified, public conversation show/list/message/status/typing/last-seen lookups can resolve any conversation for the contact, matching Chatwoot contact-level scoping; unverified contacts remain limited to the current contact inbox. | Keep in Review; reopen from B12 public API smoke or fresh reference evidence for HMAC mandatory response status drift, identifier_hash creation semantics, or display-ID ambiguity edge cases. | Focused public HMAC scoping tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.135 public conversation attachment serialization | Implemented for reused public inbox conversations: embedded messages in public conversation index/show now include Chatwoot-style attachment payloads, matching public message create/list attachment serialization. Also fixed conversation-maintenance search-index registration to preserve deterministic worker clocks for full-suite validation. | Keep in Review; reopen from B12 public conversation smoke or fresh reference evidence for raw multipart file uploads, exact attachment push-event fields, or attachment error envelope drift. | Focused public attachment/conversation and maintenance search-index tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.134 public message attachment parity | Implemented for reused public inbox messages: GoChat now accepts staged widget upload signed IDs through public message `attachments`, creates attachment rows, marks uploads completed, and serializes Chatwoot-style attachment payloads on create/list responses. | Keep in Review; reopen from B12 public message smoke or fresh reference evidence for raw multipart file uploads without direct-upload staging, exact attachment push-event fields, or attachment error envelope drift. | Focused public message attachment tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.133 public message finder window parity | Implemented for reused public inbox message index: GoChat now follows Chatwoot `MessageFinder` latest/before windows by returning the latest 20 visible messages in ascending order by default and the 20 visible messages before `before` in ascending order. | Keep in Review; reopen from B12 public message smoke or fresh reference evidence for multipart attachment uploads, exact attachment serializer drift, or after/after+before public route support if frontend starts using those params. | Focused public message finder-window tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.132 public message content-length parity | Implemented for reused public inbox messages: GoChat now follows Chatwoot public message specs and message validation by rejecting content over 150000 characters with `422` and not creating a message. | Keep in Review; reopen from B12 public message smoke or fresh reference evidence for multipart attachment uploads, exact validation error envelope drift, or MessageFinder before-window behavior. | Focused public oversized-message tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.131 public conversation message visibility parity | Implemented for reused public inbox conversations: GoChat now follows Chatwoot public conversation specs by including messages in conversation index/show payloads while filtering out private and activity messages from public responses. | Keep in Review; reopen from B12 public inbox conversation smoke or fresh reference evidence for message ordering/windowing, HMAC verified contact conversation scoping, or public conversation serializer field drift. | Focused public conversation message visibility tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.130 widget config status parity | Implemented for reused widget config: GoChat now follows Chatwoot `ConfigsController`/`WebsiteTokenHelper` by returning empty `404 Not Found` for missing/invalid `website_token`, `401 { error: "Account is suspended" }` for suspended/inactive accounts, and a successful new contact/config response for invalid widget auth tokens. | Keep in Review; reopen from B12 widget config smoke or fresh reference evidence for global-config key/value drift, IP lookup additional attributes, or JWT token payload semantics. | Focused widget config tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.129 widget contact action status parity | Implemented for reused widget contacts: GoChat now follows Chatwoot `ContactsController`/widget base-controller behavior by returning empty `404 Not Found` for missing/invalid auth context on `/api/v1/widget/contact` show/update and `/api/v1/widget/destroy_custom_attributes`, while preserving legacy `/widget/contact` unauthorized responses. | Keep in Review; reopen from B12 widget contact smoke or fresh reference evidence for phone/email discard-invalid semantics, contact identify side effects, or set-user contact-inbox replacement drift. | Focused widget contact action tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.128 widget public-support endpoint status parity | Implemented for reused widget support endpoints: GoChat now follows Chatwoot `CampaignsController`, `EventsController`, and `InboxMembersController` by returning empty `404 Not Found` for missing/invalid `website_token`, reading widget event `website_token` from the JSON body, and returning `[]` for campaigns while the account `campaigns` feature flag is disabled. | Keep in Review; reopen from B12 widget campaigns/events/inbox-members smoke or fresh reference evidence for event dispatcher payload enrichment, campaign sender serializer drift, or account feature-flag semantics. | Focused widget public-support endpoint tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.127 widget label action status parity | Implemented for reused widget labels: GoChat now follows Chatwoot `Api::V1::Widget::LabelsController` by returning empty `404 Not Found` for missing, invalid, or conversation-less auth context on `POST /api/v1/widget/labels` and `DELETE /api/v1/widget/labels/:id`, while keeping undefined account labels as successful no-ops. | Keep in Review; reopen from B12 widget labels smoke or fresh reference evidence for acts-as-taggable side effects, label serializer drift, or contact-inbox HMAC conversation scoping drift. | Focused widget label action tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.126 widget conversation missing-context status parity | Implemented for reused widget conversation helpers: GoChat now follows Chatwoot `Api::V1::Widget::BaseController#set_contact` plus `ConversationsController#render_not_found_if_empty` behavior by returning empty `404 Not Found` for missing, invalid, or conversation-less auth context on `/api/v1/widget/conversations/set_custom_attributes`, `/destroy_custom_attributes`, and `/update_last_seen`. | Keep in Review; reopen from B12 widget conversation smoke or fresh reference evidence for `toggle_typing` event dispatch status drift, custom-attribute serializer drift, or last-seen job side effects. | Focused widget conversation missing-context tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.125 widget incoming reopen parity | Implemented for reused widget `POST /api/v1/widget/messages`: GoChat now follows Chatwoot `Message#reopen_conversation` by reopening snoozed/resolved conversations on incoming widget messages unless muted, clearing `snoozed_until`, and avoiding resolved-activity side effects for snoozed reopen. | Keep in Review; reopen from B12 widget message smoke or fresh reference evidence for active bot pending-reopen behavior, API inbox special cases, or reporting-event side effects. | Focused widget incoming reopen tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.124 widget input-email identify parity | Implemented for reused widget `PATCH /api/v1/widget/messages/:id`: GoChat now follows Chatwoot input-email update behavior by deriving the visitor name from the submitted email prefix for new emails and switching the conversation/contact-inbox to an existing account contact when the submitted email already exists, preserving the existing contact name. | Keep in Review; reopen from B12 widget form smoke or fresh reference evidence for exact `submitted_email` column serialization, full `ContactIdentifyAction` validation errors, or multi-conversation contact merge side effects. | Focused widget input-email update tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.123 widget message index finder parity | Implemented for reused widget `GET /api/v1/widget/messages`: GoChat now follows Chatwoot `MessagesController#index` plus `MessageFinder` by filtering private/activity internal messages and using Chatwoot latest/before/after window limits instead of offset/limit pagination. | Keep in Review; reopen from B12 widget message smoke or fresh reference evidence for attachment serializer drift, exact meta shape drift, or multi-conversation future flow changes. | Focused widget message index tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.122 widget Dyte participant token parity | Implemented for reused widget `POST /api/v1/widget/integrations/dyte/add_participant_to_meeting`: GoChat now requires a valid widget auth token, scopes the integration message to the token contact/inbox conversation, and returns Chatwoot's invalid message type error text for non-integration messages. | Keep in Review; reopen from B12 widget Dyte smoke or fresh reference evidence for real Dyte processor response shape, integration hook credential checks, or meeting participant payload drift. | Focused widget Dyte participant tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.121 widget set-user HMAC parity | Implemented for reused widget `PATCH /api/v1/widget/contact/set_user`: GoChat now follows Chatwoot `ContactsController#set_user` by enforcing HMAC when `hmac_mandatory` is enabled or an identifier hash is supplied, while allowing custom-attributes-only updates without an identifier to skip HMAC like Chatwoot. | Keep in Review; reopen from B12 widget contact smoke or fresh reference evidence for exact invalid-token envelope, `ContactIdentifyAction` discard-invalid-attrs drift, or authenticated contact-inbox filtering edge cases. | Focused widget set-user HMAC tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.120 macro webhook event parity | Implemented for macro `send_webhook_event`: GoChat now follows Chatwoot `Macros::ExecutionService#send_webhook_event` by delivering macro webhooks with `macro.executed` in the payload/header event while keeping automation-rule webhook events as `automation_event.`. | Keep in Review; reopen from B12 macro smoke or fresh reference evidence for exact `conversation.webhook_data` field drift, webhook retry payload metadata, or durable webhook queue naming. | Focused macro webhook tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.119 widget message reply-to parity | Implemented for reused widget message creation: GoChat now reuses the latest widget conversation when `conversation_id` is omitted, accepts nested `message.reply_to`, writes `content_attributes.in_reply_to` only for reply targets that exist in the same conversation, and omits reply metadata for invalid reply IDs like Chatwoot. | Keep in Review; reopen from B12 widget message smoke or fresh reference evidence for external-id reply metadata, verified contact-inbox conversation scoping, or reply serialization drift. | Focused widget reply-to tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.118 widget message content-length parity | Implemented for reused widget message creation: GoChat now rejects `POST /api/v1/widget/messages` content longer than Chatwoot's 150000-character `Message` limit with raw `422 { message: "Content is too long (maximum is 150000 characters)" }` and performs the check before creating a conversation or message. | Keep in Review; reopen from B12 widget message smoke or fresh reference evidence for processed-content length drift, multipart attachment-only edge cases, Unicode length semantics, or public API message length parity. | Focused widget oversized-message test must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.117 widget first-message conversation attributes parity | Implemented for reused widget message creation: GoChat now accepts `custom_attributes` and `labels` on `POST /api/v1/widget/messages`, applies them only when a new conversation is created by the first message, filters labels to existing account labels, de-duplicates valid labels, and ignores these params for later messages on an existing conversation. | Keep in Review; reopen from B12 widget message smoke or fresh reference evidence for multipart label array edge cases, label/tag association-table requirements beyond legacy label text, invalid-label telemetry, or browser metadata/referrer serialization drift. | Focused widget message metadata tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.116 widget contact custom-attribute route parity | Implemented for reused widget contact custom-attribute deletion: GoChat now exposes Chatwoot's `POST /api/v1/widget/destroy_custom_attributes` route, deletes only requested contact custom-attribute keys, returns updated contact JSON, and retains the older local `/contact/destroy_custom_attributes` alias for compatibility. | Keep in Review; reopen from B12 widget contact smoke or fresh reference evidence for exact invalid website-token `404` shape, hmac-mandatory custom-attribute edge cases, or contact payload serializer drift. | Focused widget contact custom-attribute tests must pass; full `go test ./...` and `git diff --check` must pass. Route artifact regeneration is optional unless the tracked route set is refreshed. | +| 0 | P3.115 widget transcript delivery parity | Implemented for reused widget `transcript`: GoChat now follows inspected `Api::V1::Widget::ConversationsController#transcript` by sending transcripts to the widget contact email through the fakeable transcript delivery boundary, incrementing account outbound-email counts, returning empty `429` when no conversation exists, empty `402` when transcript email is unavailable, and empty `429` when rate limited. | Keep in Review; reopen from B12 widget transcript smoke or fresh reference evidence for exact mailer HTML body, cloud-only global default limits, contact-without-email side effects, or locale-specific transcript templates. | Focused widget transcript tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.114 widget end-conversation gate parity | Implemented for reused widget `toggle_status`: GoChat now follows inspected `Api::V1::Widget::ConversationsController#toggle_status` by requiring the web-widget `end_conversation` feature flag, returning empty `403` when disabled, returning empty `404` for widget tokens with no conversation, leaving already-resolved conversations unchanged, and creating a visitor-resolved activity message when an open conversation is resolved. | Keep in Review; reopen from B12 widget smoke or fresh reference evidence for exact activity job timing, localized visitor names, widget feature-flag storage drift, or public API toggle-status drift. | Focused widget toggle-status tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.113 widget conversation response parity | Implemented for reused widget conversation action calls: GoChat now follows inspected `Api::V1::Widget::ConversationsController` response semantics by returning success with no JSON body for `toggle_typing`, `update_last_seen`, `toggle_status`, and implicit `set_custom_attributes`, while preserving JSON conversation rendering for `destroy_custom_attributes`. | Keep in Review; reopen from B12 widget smoke or fresh reference evidence for `end_conversation?` gating, widget not-found status shape, contact custom-attribute response drift, or transcript rate-limit/payment semantics. | Focused widget conversation action tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.112 CSAT review-notes update parity | Implemented for reused enterprise CSAT review-note edits: GoChat now follows inspected `Enterprise::Api::V1::Accounts::CsatSurveyResponsesController#update` by finding the CSAT response through the current account scope, updating only `csat_review_notes`, `review_notes_updated_by`, and `review_notes_updated_at`, and preserving rating/feedback fields even if clients send them. | Keep in Review; reopen from B12 CSAT review-note smoke or fresh reference evidence for feature-gate/paywall enforcement, exact Pundit role/custom-role policy, or update route shape drift. | Focused CSAT update tests must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.111 CSAT date-range boundary parity | Implemented for reused CSAT report list/metrics/download filters: GoChat now follows inspected `DateRangeHelper#range` and `CsatSurveyResponsesController#set_total_sent_messages_count` by applying created-at filters only when both `since` and `until` are present and by using a lower-inclusive, upper-exclusive range. | Keep in Review; reopen from B12 CSAT report smoke or fresh reference evidence for timezone parsing, invalid timestamp error handling, Sift sorting edge cases, or additional report filters. | Focused CSAT list date-boundary test must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.110 CSAT CSV recorded-at parity | Implemented for reused CSAT report downloads: GoChat now follows inspected `download.csv.erb` by serializing the CSV `Recorded date` column as Chatwoot/Rails-style `YYYY-MM-DD HH:MM:SS ZONE` strings instead of local RFC3339 timestamps. | Keep in Review; reopen from B12 CSAT report smoke or fresh reference evidence for locale-specific time zones, CSV locale headers, enterprise review-note gating, or additional download filters. | Focused CSAT download timestamp test must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.109 CSAT reports fixed pagination parity | Implemented for reused CSAT report list calls: GoChat now follows inspected `CsatSurveyResponsesController::RESULTS_PER_PAGE = 25` and the reused dashboard `csatReports.get` client by ignoring local `per_page` query overrides while still honoring Chatwoot `page`, date, agent, inbox, team, rating, and `sort=-created_at` request shape. | Keep in Review; reopen from B12 CSAT report smoke or fresh reference evidence for Sift sorting edge cases, CSV locale formatting, enterprise review-note gating, or additional report filters. | Focused CSAT handler pagination test must pass; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.108 CSAT messaging-window send parity | Implemented for reused CSAT survey sends: GoChat now follows inspected `MessageTemplates::Template::CsatSurvey`, `CsatSurveyService#conversation_allows_csat?`, `within_messaging_window?`, and `create_csat_not_sent_activity_message` behavior by creating a generic `input_csat` survey with Chatwoot `template` message type only for resolved non-tweet conversations when Chatwoot-style channel message-window rules allow it, and by persisting an activity message with `CSAT survey not sent due to outgoing message restrictions` when the window is closed. Windowless channels such as web widget are not blocked by `allow_messages_after_resolved`, while WhatsApp/TikTok/Meta/Twilio-WhatsApp windows require recent incoming messages and API windows honor nested `additional_attributes.agent_reply_time_window`. | Keep in Review; reopen from B12 public/widget CSAT smoke or fresh reference evidence for global-config extended Meta windows, provider-template outgoing delivery branches, exact API channel additional-attribute storage drift, Twitter inbox-type drift, or localized activity-message text drift. | Focused CSAT listener tests passed. No route artifacts change. | | 0 | P3.107 Captain custom tool policy and auth serialization parity | Implemented for reused Captain custom-tool security behavior: list/show/create/update now omit `auth_config` unless the request role is administrator/super-admin, while create/test/update/delete enforce the inspected `Captain::CustomToolPolicy` mutating-action administrator gate for authenticated role contexts. Agent roles can still list/show custom tools without auth secrets, but mutating/test actions receive raw `403 { error: "You are not authorized to do this action" }`. | Keep in Review; reopen from B12 Captain custom-tools smoke or fresh reference evidence for exact custom-role policy behavior, account-user lookup when role context is absent, or deeper Pundit authorization ordering beyond the inspected policy/Jbuilder contract. | Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P3.106 Captain custom tool validation parity | Implemented for reused Captain custom-tool create/update validation: GoChat now maps blank title/endpoint URL, explicit duplicate slug, unsupported `GET`/`POST`-only `http_method`, unsupported `none`/`bearer`/`basic`/`api_key` `auth_type`, generated slug length overflow, and invalid `param_schema` array item shape into Chatwoot-style raw `422 { message, attributes }` responses. `param_schema` items require string `name`, `type`, and `description`, allow boolean `required`, and reject extra keys. | Keep in Review; reopen from B12 Captain custom-tools smoke or fresh reference evidence for exact Rails `JSONSchemer` additional-property wording, title max-length frontend-only enforcement, update clearing semantics for blank fields, or admin-only `auth_config` serializer gating. | Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P3.105 Captain custom tool create limits and slug parity | Implemented for reused Captain custom-tool creation: GoChat now mirrors `Captain::CustomTool` by allowing at most 15 custom tools per account, returning raw `422 { error: "You can create a maximum of 15 custom tools per account" }` when the cap is exceeded, generating default slugs as `custom_` + parameterized title with underscore separators, capping generated slugs at 64 characters, adding `_xxxxxx` lowercase alphanumeric suffixes on same-account collisions, and keeping the uniqueness boundary scoped to `(account_id, slug)` like the Chatwoot schema. | Keep in Review; reopen from B12 Captain custom-tools smoke or fresh reference evidence for exact Rails `parameterize` locale transliteration, concurrent account-row locking, auth-config administrator-only serialization, enum/schema validation response shape, or slug-generation exhaustion wording beyond the inspected model/controller contract. | Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | @@ -950,7 +985,7 @@ Upcoming enterprise task boards: | 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 | 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. | Review; frontend-critical CRUD/execute done by `feat(macros): align chatwoot macro payloads` | | 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 side effects, retries, and observable failures; reference audit proves no explicit delayed action params exist. | Review by `feat(automation): close delayed action parity`; current reference has no delayed action params, P5.12 covers scheduled items, `send_email_to_team` queues durable team notification jobs, and `add_sla` applies account-scoped SLA state idempotently | -| B9 | B9.4 | Review deeper macro attachments/files and durable queued execution once the worker path is selected. | Chatwoot macro attachments and `MacrosExecutionJob`. | Attachment/file tests or explicit durable-worker split. | Todo | +| B9 | B9.4 | Review deeper macro attachments/files and durable queued execution once the worker path is selected. | Chatwoot macro attachments, `MacrosExecutionJob`, and macro `send_webhook_event`. | Attachment/file tests or explicit durable-worker split. | Review; macro attachments/files, durable queued display-ID execution, and `macro.executed` webhook event parity are covered by focused tests | | B10 | B10.1 | Align audit log serializer, filters, pagination, actor/request metadata, and admin route behavior. | Chatwoot enterprise audit controllers/models. | Audit list tests plus representative mutation writer tests. | Done by `feat(audit): align chatwoot audit log payloads`; writer coverage continues in B10.2 | | B10 | B10.2 | Add audit writer coverage for representative core and enterprise mutations: inbox, conversation assignment/status, SLA policy, capacity policy, custom role, automation, macro, CSAT review notes. | Chatwoot audit hooks and current Go service mutation points. | Mutation tests assert audit rows with actor, auditable type/id, account, IP/request metadata where available. | Done by `feat(audit): record enterprise mutations` and `feat(audit): cover operational mutations` | | B10 | B10.3 | Align CustomRole permission keys, account-user role resolution, deletion nullification, and authorization failure payloads. | Chatwoot custom role controllers/policies and permission constants. | Permission matrix tests for admin/non-admin/custom-role access. | Done by `feat(custom-roles): align chatwoot permissions` | @@ -971,6 +1006,7 @@ B8 CSAT execution breakdown: | B8.2c | Make response creation/update message-linked and idempotent: one CSAT response per `input_csat` message, updates mutate the existing response, and repeated public submissions do not create extra rows. | `CsatSurveys::ResponseBuilder`, `Message#csat_survey_response`, CSAT response model uniqueness. | Service tests count rows after repeated public updates and assert message/contact/conversation/assignee linkage. | Done by `ef3a909` | | B8.3a | Replace the current resolve listener placeholder with Chatwoot-style survey message creation when an inbox has CSAT enabled. | `reference/chatwoot/app/listeners/csat_survey_listener.rb`, `CsatSurveyService`, inbox CSAT settings. | Listener tests prove disabled inboxes do not send, enabled inboxes create exactly one `input_csat` outgoing message, and repeated resolve events are idempotent. | Done by `ef3a909` | | B8.3b | Keep CSAT survey sending inside a clear job boundary even if execution remains synchronous for now, so durable worker migration can happen under Phase 5 without changing behavior. | Chatwoot listener/job boundary and existing Go channel dispatcher. | Unit tests cover enqueue/perform boundary or documented synchronous fallback with idempotency. | Review; `ef3a909` isolates behavior in `SendSurveyForConversation`, durable queue remains Phase 5 | +| B8.3c | Align CSAT survey send behavior when the conversation cannot receive outgoing replies. | `reference/chatwoot/app/services/csat_survey_service.rb`, `reference/chatwoot/app/services/message_templates/template/csat_survey.rb`, `reference/chatwoot/app/services/conversations/message_window_service.rb`, locale key `conversations.activity.csat.not_sent_due_to_messaging_window`. | Listener tests prove tweet conversations skip CSAT entirely, channel-windowless web widget conversations still create `input_csat`, closed WhatsApp/API windows create an activity message instead of `input_csat`, recent incoming WhatsApp messages permit survey creation, API conversations without `agent_reply_time_window` stay windowless, and generic survey messages use Chatwoot's `template` message type. | Review; `P3.108` implements the non-tweet guard, channel message-window boundary, generic template survey type, and activity payload | | B8.4a | Align CSAT download CSV with Chatwoot report filters and columns, including review notes where the frontend exposes them. | CSAT report/download controller, dashboard reports API. | CSV tests cover date, agent, inbox, team, rating filters and expected column names/order. | Done by `b36cf07` | B9 automation and macro execution breakdown: @@ -2000,7 +2036,7 @@ Serializer parity work plan: | S3 | Contacts and companies | `reference/chatwoot/app/controllers/api/v1/accounts/contacts*`, enterprise `companies*` | fixture tests for list/show/search/merge/relation/shared attachment/avatar payloads | Doing; contact shared attachments, conversation shared-file payloads, company multipart avatar form bodies, and fixed company pagination now have focused Chatwoot-style coverage. | | S4 | Inboxes and channels | `reference/chatwoot/app/controllers/api/v1/accounts/inboxes*`, channel controllers | fixture tests for inbox CRUD, channel settings, widget config | Done | | S5 | Notifications and settings | `reference/chatwoot/app/controllers/api/v1/accounts/notifications*` | fixture tests for notification list/actions/settings | Review; list envelope, includes filters, unread counts, read_all, unread/snooze/update raw responses, and destroy_all read/all are covered. | -| S6 | Reports and CSAT | `reference/chatwoot/app/controllers/api/v1/accounts/reports*`, `csat_survey_responses*` | fixture tests for report filters and CSAT metrics/list | Doing | +| S6 | Reports and CSAT | `reference/chatwoot/app/controllers/api/v1/accounts/reports*`, `csat_survey_responses*` | fixture tests for report filters and CSAT metrics/list | Doing; CSAT metrics/list payloads, filters, date-range boundaries, review-note update semantics, CSV export, CSV recorded-at formatting, message-window send behavior, and fixed 25-row report pagination now have focused Chatwoot-style coverage. | | S7 | Widget/public | `reference/chatwoot/app/controllers/api/v1/widget*`, `public/api/v1*` | widget smoke fixtures and public flow tests | Doing | | S8 | Search | `reference/chatwoot` search controllers plus frontend search client | Meilisearch-backed search response fixtures | Review | | S9 | Account webhooks | `reference/chatwoot/app/controllers/api/v1/accounts/webhooks_controller.rb`, account webhook Jbuilder views, dashboard webhooks store/API | fixture tests for webhook list/create/update/delete payloads | Review | @@ -2823,3 +2859,118 @@ Verification milestone gates: - 2026-06-07: P3.105 Captain custom-tool create limits and slug checkpoint prepared as `feat(captain): align custom tool limits`; audited Chatwoot `Captain::CustomTool` constants/callbacks, `CustomToolsController#create` limit rescue, and `_custom_tool.json.jbuilder`. GoChat now enforces the 15-tools-per-account cap, returns raw `422` with the reference limit message, generates omitted slugs as `custom_` + underscore-parameterized title, caps generated slugs at 64 characters, appends `_xxxxxx` lowercase alphanumeric suffixes on account-scoped collisions, and aligns the GORM uniqueness tag with the reference `(account_id, slug)` index. Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.106 Captain custom-tool validation checkpoint prepared as `feat(captain): validate custom tools`; audited Chatwoot `Captain::CustomTool` model validations, enum lists, `PARAM_SCHEMA_VALIDATION`, `JsonSchemaValidator`, and `RequestExceptionHandler#render_record_invalid`. GoChat now returns raw `422 { message, attributes }` for blank title/endpoint URL, duplicate explicit slugs, unsupported `http_method`/`auth_type`, overlong generated slugs, and invalid `param_schema` items; schema items require string `name`/`type`/`description`, allow boolean `required`, and reject extra keys. Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.107 Captain custom-tool policy/auth-config checkpoint prepared as `feat(captain): secure custom tool auth config`; audited Chatwoot `Captain::CustomToolPolicy` and `_custom_tool.json.jbuilder` administrator-only `auth_config` branch. GoChat now omits `auth_config` from non-admin list/show/create/update payloads, exposes it for administrator/super-admin request roles, allows agent roles to list/show tools without secrets, and returns raw `403 { error: "You are not authorized to do this action" }` for agent create/test/update/delete attempts. Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.108 CSAT messaging-window checkpoint prepared as `feat(csat): respect survey message windows`; audited Chatwoot `MessageTemplates::Template::CsatSurvey`, `CsatSurveyService#conversation_allows_csat?`, `within_messaging_window?`, `create_csat_not_sent_activity_message`, `Conversations::MessageWindowService`, and the `conversations.activity.csat.not_sent_due_to_messaging_window` locale payload. GoChat now creates the generic `input_csat` survey with Chatwoot `template` message type only for resolved non-tweet conversations when channel-specific message-window rules allow it and otherwise persists a Chatwoot-style activity message with `CSAT survey not sent due to outgoing message restrictions`; web widget remains windowless, nested API `additional_attributes.agent_reply_time_window` is honored, and recent incoming messages keep windowed channels such as WhatsApp eligible. Focused CSAT listener/service tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.109 CSAT report pagination checkpoint prepared as `fix(csat): align report page size`; audited Chatwoot `Api::V1::Accounts::CsatSurveyResponsesController`, `RESULTS_PER_PAGE = 25`, `index.json.jbuilder`, and reused dashboard `api/csatReports.js`. GoChat CSAT report list requests now ignore local `per_page` overrides and always use the Chatwoot fixed 25-row page size while preserving page/date/agent/inbox/team/rating filters and raw array response shape. Focused CSAT handler pagination tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.110 CSAT CSV recorded-at checkpoint prepared as `fix(csat): align csv timestamps`; audited Chatwoot `api/v1/accounts/csat_survey_responses/download.csv.erb`, which writes `csat_response.created_at` directly into the CSV row. GoChat CSAT report downloads now format the `Recorded date` column as Chatwoot/Rails-style `YYYY-MM-DD HH:MM:SS ZONE` strings instead of RFC3339 while retaining P3.109 fixed 25-row list pagination and existing CSV filters. Focused CSAT download timestamp tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.111 CSAT date-range boundary checkpoint prepared as `fix(csat): align date ranges`; audited Chatwoot `DateRangeHelper#range`, `CsatSurveyResponsesController#set_csat_survey_responses`, and `set_total_sent_messages_count`. GoChat CSAT report list/metrics/download filters now apply date filtering only when both `since` and `until` are present and use a lower-inclusive, upper-exclusive range, matching Rails `start...finish`; single-sided date params are ignored like the reference helper. Focused CSAT date-boundary tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.112 CSAT review-notes update checkpoint prepared as `fix(csat): scope review updates`; audited Chatwoot enterprise `CsatSurveyResponsesController#update`, `csatReports.update`, and reused `CsatExpandedRow` review-note flow. GoChat CSAT report updates now find responses through the current account scope, update only `csat_review_notes` plus reviewer metadata, preserve rating/feedback fields even if clients send them, and return the Chatwoot CSAT response serializer. Focused CSAT update tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.113 widget conversation response checkpoint prepared as `fix(widget): align conversation action responses`; audited Chatwoot `Api::V1::Widget::ConversationsController`, widget conversation request specs, and reused widget `api/conversation.js`. GoChat widget conversation actions now return empty success responses for `toggle_typing`, `update_last_seen`, `toggle_status`, and implicit `set_custom_attributes`, while keeping `destroy_custom_attributes` aligned with the reference controller's explicit JSON conversation render. Focused widget conversation action tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.114 widget end-conversation gate checkpoint prepared as `fix(widget): gate conversation close`; audited Chatwoot `Api::V1::Widget::ConversationsController#toggle_status`, widget controller request specs, and web-widget feature-flag defaults. GoChat widget `toggle_status` now requires the `end_conversation` selected feature flag, returns empty `403` when closing is disabled, returns empty `404` when the widget token has no conversation, avoids duplicate resolution work for already-resolved conversations, and records a Chatwoot-style visitor-resolved activity message for open conversations. Focused widget toggle-status tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.115 widget transcript delivery checkpoint prepared as `fix(widget): send conversation transcripts`; audited Chatwoot `Api::V1::Widget::ConversationsController#transcript`, widget conversation request specs, account transcript gates, and reused widget transcript API calls. GoChat widget transcripts now deliver to the widget contact email through the fakeable transcript boundary, increment outbound email counts, preserve empty `200` for successful/no-email contacts, return empty `429` when no conversation exists or email quota is exhausted, and return empty `402` when transcript email is disabled for the account. Focused widget transcript tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.116 widget contact custom-attribute route checkpoint prepared as `fix(widget): align contact attribute deletion`; audited Chatwoot `Api::V1::Widget::ContactsController#destroy_custom_attributes`, widget contact request specs, and reused widget contact API calls. GoChat now registers the reused frontend route `POST /api/v1/widget/destroy_custom_attributes`, deletes only the requested contact custom-attribute keys, returns the updated contact JSON, and retains the older local `/contact/destroy_custom_attributes` alias. Focused widget contact custom-attribute tests passed; full `go test ./...` and `git diff --check` passed. Route artifact regeneration is optional unless the tracked route set is refreshed. +- 2026-06-09: P3.117 widget first-message conversation attributes checkpoint prepared as `fix(widget): apply first message metadata`; audited Chatwoot `WidgetMessagesController#create`, widget message request specs, and reused widget endpoint builders for `custom_attributes` and `labels`. GoChat now accepts widget message `custom_attributes` and `labels`, applies them only when the first message creates a new conversation, filters labels to existing account labels, de-duplicates valid labels, and ignores metadata params for later messages on an existing conversation. Focused widget message metadata tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.118 widget message content-length checkpoint prepared as `fix(widget): reject oversized messages`; audited Chatwoot `Message` content validation and `WidgetMessagesController#create` request specs. GoChat widget messages now reject content longer than 150000 characters with raw `422 { message: "Content is too long (maximum is 150000 characters)" }` before creating any conversation or message, while preserving P3.117 first-message metadata behavior for valid messages. Focused widget oversized-message tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.119 widget message reply-to checkpoint prepared as `fix(widget): align reply metadata`; audited Chatwoot `WidgetMessagesController#create`, widget `message_params`, and widget message request specs for valid and invalid `reply_to`. GoChat widget messages now reuse the latest conversation when `conversation_id` is omitted, accept nested `message.reply_to`, persist `content_attributes.in_reply_to` only when the referenced message exists in the same conversation, and omit reply metadata for invalid reply IDs. Focused widget reply-to tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.120 macro webhook event checkpoint prepared as `fix(macros): align webhook event`; audited Chatwoot `Macros::ExecutionService#send_webhook_event`, `MacrosExecutionJob`, and macro execution specs. GoChat macro webhook actions now deliver payload/header event `macro.executed` while preserving automation-rule webhook `automation_event.` behavior and durable webhook job replay. Focused macro webhook tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.121 widget set-user HMAC checkpoint prepared as `fix(widget): align set-user hmac`; audited Chatwoot `Api::V1::Widget::ContactsController#set_user`, `should_verify_hmac?`, and widget contact specs. GoChat now parses `hmac_mandatory` from web-widget config, rejects missing/invalid identifier hashes when mandatory HMAC applies, and keeps Chatwoot's custom-attributes-only no-identifier path free of HMAC enforcement. Focused widget set-user HMAC tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.122 widget Dyte participant checkpoint prepared as `fix(widget): scope dyte participants`; audited Chatwoot `Api::V1::Widget::Integrations::DyteController#add_participant_to_meeting` and widget Dyte request specs. GoChat now requires widget auth for Dyte participant creation, verifies the message belongs to the authenticated contact/inbox conversation, and returns Chatwoot's invalid-message-type text for non-integration messages while preserving the existing fake Dyte token response boundary. Focused widget Dyte participant tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.123 widget message index checkpoint prepared as `fix(widget): filter message index`; audited Chatwoot `Api::V1::Widget::MessagesController#index`, widget message request specs, and `MessageFinder`. GoChat widget message index now filters private/activity internal messages, uses Chatwoot latest/before/after windows, and returns the latest 20 visible messages in ascending order rather than offset/limit pagination. Focused widget message index tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.124 widget input-email identify checkpoint prepared as `fix(widget): identify email submissions`; audited Chatwoot `Api::V1::Widget::MessagesController#update` and widget message update specs. GoChat now derives a new contact name from submitted email prefix and switches input-email submissions to an existing account contact when the submitted email already exists, preserving that existing contact name and deleting the transient visitor contact. Focused widget input-email tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.125 widget incoming reopen checkpoint prepared as `fix(widget): reopen on incoming`; audited Chatwoot `Message#reopen_conversation`, `Conversation#toggle_status`, and widget message request specs. GoChat widget incoming messages now reopen snoozed/resolved conversations unless muted, clear `snoozed_until`, and avoid resolved-activity messages when snoozed conversations open from visitor replies. Focused widget incoming reopen tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.126 widget conversation missing-context status checkpoint prepared as `fix(widget): align conversation 404s`; audited Chatwoot `Api::V1::Widget::BaseController`, `ConversationsController#set_custom_attributes/#destroy_custom_attributes/#update_last_seen`, and widget conversation request specs. GoChat Chatwoot widget conversation helper routes now return empty `404 Not Found` for missing auth token, invalid auth token, or no latest conversation on set/destroy custom attributes and update-last-seen. Focused widget conversation helper tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.127 widget label action status checkpoint prepared as `fix(widget): align label 404s`; audited Chatwoot `Api::V1::Widget::LabelsController`, widget base-controller contact setup, and widget label request specs. GoChat Chatwoot widget label create/delete routes now return empty `404 Not Found` for missing auth token, invalid auth token, or no latest conversation, while preserving Chatwoot's successful no-op for undefined account labels. Focused widget label tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.128 widget public-support endpoint status checkpoint prepared as `fix(widget): align public support 404s`; audited Chatwoot `Api::V1::Widget::CampaignsController`, `EventsController`, `InboxMembersController`, and their request specs. GoChat Chatwoot widget campaigns/events/inbox-members now return empty `404 Not Found` for missing or invalid `website_token`; widget events accept `website_token` from the JSON body; campaigns return `[]` when the account `campaigns` feature flag is disabled. Focused widget public-support endpoint tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.129 widget contact action status checkpoint prepared as `fix(widget): align contact 404s`; audited Chatwoot `Api::V1::Widget::ContactsController`, widget base-controller setup, and widget contact request specs. GoChat Chatwoot widget contact show/update and destroy-custom-attributes routes now return empty `404 Not Found` for missing or invalid widget auth context while preserving legacy `/widget/contact` unauthorized behavior. Focused widget contact action tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.130 widget config status checkpoint prepared as `fix(widget): align config statuses`; audited Chatwoot `Api::V1::Widget::ConfigsController`, `WebsiteTokenHelper#set_web_widget`, and widget config request specs. GoChat Chatwoot widget config now returns empty `404 Not Found` for missing/invalid website tokens, returns `401 { error: "Account is suspended" }` for suspended/inactive accounts, and keeps the successful new-contact config response for invalid widget auth tokens. Focused widget config tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.131 public conversation message visibility checkpoint prepared as `fix(public): filter conversation messages`; audited Chatwoot `Public::Api::V1::Inboxes::ConversationsController` and public conversation request specs. GoChat public inbox conversation index/show payloads now include visible messages and filter private/activity messages from public responses. Focused public conversation visibility tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.132 public message content-length checkpoint prepared as `fix(public): reject oversized messages`; audited Chatwoot `Public::Api::V1::Inboxes::MessagesController` and public message request specs. GoChat public inbox message creation now rejects content longer than 150000 characters with `422` before creating a message, reusing the Chatwoot message validation text. Focused public oversized-message tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.133 public message finder window checkpoint prepared as `fix(public): align message windows`; audited Chatwoot `Public::Api::V1::Inboxes::MessagesController`, `MessageFinder`, and public message request specs. GoChat public inbox message index now returns the latest 20 visible messages in ascending order by default and honors `before` by returning the 20 visible messages before that ID in ascending order. Focused public message finder-window tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.134 public message attachment checkpoint prepared as `fix(public): attach public uploads`; audited Chatwoot `Public::Api::V1::Inboxes::MessagesController#build_attachment`, public message request specs, and attachment push-event payload views. GoChat public inbox message creation now accepts staged widget upload signed IDs through multipart/JSON `attachments`, creates attachment rows, marks direct uploads completed, and serializes Chatwoot-style attachment payloads on create/list responses. Focused public message attachment tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. + +- 2026-06-09: P3.135 public conversation attachment serialization checkpoint prepared as `fix(public): embed conversation attachments`; extended the P3.134 public attachment behavior across public conversation index/show embedded message payloads so attachment messages serialize consistently whether fetched through message routes or conversation routes. Also fixed conversation maintenance search-index registration to preserve the existing deterministic worker clock when search indexing is added, keeping queued maintenance index jobs processable during full-suite validation. Focused public attachment/conversation tests, focused conversation maintenance search-index tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + +- 2026-06-09: P3.136 public HMAC conversation scoping checkpoint prepared as `fix(public): align hmac conversation scope`; audited Chatwoot `Public::Api::V1::Inboxes::ContactsController#process_hmac` and `ConversationsController#set_conversation`, which switch verified contact inboxes from contact-inbox scoped conversations to contact-scoped conversations. GoChat public conversation lookup now keeps unverified contacts limited to the current contact inbox while allowing HMAC-verified contacts to show/list message/status/typing/last-seen on any conversation for the contact across inbox contact-inbox records. Focused public HMAC scoping tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + +- 2026-06-09: P3.137 public API HMAC mandatory checkpoint prepared as `fix(public): require api hmac when mandatory`; audited Chatwoot `Channel::Api#hmac_mandatory`, public inbox show Jbuilder `identity_validation_enabled`, and `Public::Api::V1::Inboxes::ContactsController#process_hmac`. GoChat now models API-channel `hmac_mandatory`, exposes public inbox identity validation from that field instead of token presence, rejects public contact create/update when mandatory HMAC is missing or invalid, and still marks valid HMAC-created contact inboxes verified. Focused public HMAC mandatory/contact tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + +- 2026-06-09: P3.138 API channel HMAC persistence checkpoint prepared as `fix(inboxes): sync api hmac channel fields`; audited Chatwoot `Channel::Api` schema/editable attrs and GoChat public API lookup/reset-secret use of `channel_api`. GoChat inbox create/update now syncs API channel config into `channel_api` rows, including identifier, HMAC token, `hmac_mandatory`, webhook URL, and the inbox `ChannelID`, so public API HMAC mandatory behavior is backed by dashboard/API inbox persistence instead of test-only seed data. Focused API channel persistence tests, focused inbox handler parity regression, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + +- 2026-06-09: P3.139 API channel additional attributes checkpoint prepared as `fix(inboxes): sync api channel attributes`; audited Chatwoot `Channel::Api` schema/editable attrs, including JSONB `additional_attributes` and `agent_reply_time_window` validation context. GoChat now models API-channel `additional_attributes` and syncs inbox channel config additional attributes into `channel_api` on create/update alongside identifier, HMAC token, mandatory HMAC, webhook URL, and `ChannelID`. Focused API channel additional-attribute tests, focused inbox handler parity regression, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + +- 2026-06-09: P3.140 API channel reply-window validation checkpoint prepared as `fix(inboxes): validate api reply window`; audited Chatwoot `Channel::Api#ensure_valid_agent_reply_time_window`, which rejects non-positive configured `additional_attributes.agent_reply_time_window`. GoChat API inbox create/update now rejects zero, negative, or non-integer reply-window values before saving `channel_api`, while continuing to persist positive values. Focused API reply-window validation tests, focused inbox handler parity regression, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + +- 2026-06-09: P3.141 API channel secret persistence checkpoint prepared as `fix(inboxes): sync api channel secret`; audited Chatwoot `Channel::Api` schema and `WebhookListener#deliver_api_inbox_webhooks`, which uses `inbox.channel.secret` for `api_inbox_webhook` signing. GoChat now models API-channel `secret` and syncs the generated inbox API secret into `channel_api` on create/update alongside identifier, HMAC token, mandatory HMAC, webhook URL, and additional attributes. Focused API channel secret persistence tests, focused inbox handler parity regression, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.142 API channel reset-secret checkpoint prepared as `fix(inboxes): reset api webhook secret`; audited Chatwoot `Api::V1::Accounts::InboxesController#reset_secret`, `Channel::Api`, and `WebhookSecretable#reset_secret!`, which regenerate the webhook signing `secret` rather than the public HMAC token. GoChat API inbox reset now updates `channel_api.secret` and the mirrored inbox secret while leaving `channel_api.hmac_token` unchanged for public contact identity validation. Focused API reset-secret service tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.143 API inbox webhook delivery checkpoint prepared as `fix(webhooks): deliver api inbox webhooks`; audited Chatwoot `WebhookListener#deliver_webhook_payloads` and `#deliver_api_inbox_webhooks`, which enqueue `api_inbox_webhook` deliveries to `inbox.channel.webhook_url` signed with `inbox.channel.secret` for message/conversation webhook events. GoChat now registers the generic channel `WebhookListener`, resolves API channel webhook settings from `channel_api`, POSTs Chatwoot-style payloads with underscore event names to configured API inbox webhook URLs, and signs payloads with the API webhook secret while skipping blank URLs. Focused API inbox webhook listener tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.144 account webhook delivery checkpoint prepared as `fix(webhooks): deliver account webhooks`; audited Chatwoot `WebhookListener#deliver_account_webhooks`, which iterates active account webhooks and delivers only when `webhook.subscriptions` includes the payload event. GoChat now resolves active account `webhook_subscriptions`, filters by Chatwoot underscore event names, POSTs signed `account_webhook` payloads, and keeps API inbox webhook delivery in the same listener path. Focused account/API webhook listener tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.145 typing webhook delivery checkpoint prepared as `fix(webhooks): deliver typing webhooks`; audited Chatwoot `WebhookListener#conversation_typing_on` and `#conversation_typing_off`, which deliver typing payloads through both account webhooks and API inbox webhooks. GoChat now routes `conversation.typing_on/off` through the generic webhook listener, maps them to Chatwoot `conversation_typing_on/off` event names, filters account subscriptions accordingly, and keeps API inbox webhook delivery signed with the API channel secret. Focused typing/account/API webhook listener tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.146 contact webhook delivery checkpoint prepared as `fix(webhooks): deliver contact webhooks`; audited Chatwoot `WebhookListener#contact_created` and `#contact_updated`, where contact-created always delivers account webhooks and contact-updated returns early when changed attributes are blank. GoChat now routes `contact.created/updated` through the generic webhook listener, maps them to `contact_created/updated`, formats changed attributes as Chatwoot previous/current values, and skips `contact_updated` without changes. Focused contact/account webhook listener tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.147 inbox webhook delivery checkpoint prepared as `fix(webhooks): deliver inbox webhooks`; audited Chatwoot `WebhookListener#inbox_created` and `#inbox_updated`, where inbox-created always delivers account webhooks and inbox-updated returns early when changed attributes are blank. GoChat now routes `inbox.created/updated` through the generic webhook listener, maps them to `inbox_created/updated`, formats changed attributes as Chatwoot previous/current values, and skips `inbox_updated` without changes. Focused inbox/account webhook listener tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.148 conversation status webhook delivery checkpoint prepared as `fix(webhooks): deliver conversation status webhooks`; audited Chatwoot `WebhookListener#conversation_status_changed`, which delivers account and API inbox webhooks with `event: conversation_status_changed` plus changed attributes. GoChat now maps local opened/resolved conversation events to Chatwoot `conversation_status_changed`, preserves previous/current status changed attributes, and delivers through both account webhook subscriptions and API inbox webhook URLs. Focused conversation status/account/API webhook listener tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.149 webhook delivery header checkpoint prepared as `fix(webhooks): add chatwoot delivery header`; audited Chatwoot `Webhooks::Trigger#request_headers`, which adds `X-Chatwoot-Delivery` when a delivery id is provided by `WebhookListener`/`WebhookJob`. GoChat now generates a UUID delivery id for each outgoing account/API inbox webhook POST and sends it in `X-Chatwoot-Delivery` alongside the Chatwoot timestamp/signature headers. Focused webhook listener header tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.150 message webhook sendable checkpoint prepared as `fix(webhooks): skip activity message webhooks`; audited Chatwoot `MessageFilterHelpers#webhook_sendable?`, which returns false for activity messages. GoChat now skips `message.created/updated` webhook delivery when the message type is `activity`, matching Chatwoot behavior, while continuing to deliver incoming/outgoing/template messages through account and API inbox webhook paths. Focused activity-message-skip and outgoing-message-deliver tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.151 api inbox webhook failure message-status checkpoint prepared as `fix(webhooks): mark message failed on webhook error`; audited Chatwoot `Webhooks::Trigger#handle_error` and `#update_message_status`, which marks messages as `failed` when API inbox webhook delivery fails for message_created/message_updated events. GoChat now updates message status to `failed` in the database when API inbox webhook POST returns an error for message events, while account webhook errors are logged but do not affect message status. Focused webhook failure message-status tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.152 conversation updated webhook changed-attributes checkpoint prepared as `test(webhooks): verify conversation updated changed attributes`; audited Chatwoot `WebhookListener#conversation_updated` and its spec, which verifies nested custom_attributes changed-attribute formatting. GoChat now has focused tests verifying conversation_updated webhook payloads format nested changed_attributes as Chatwoot previous/current values. Focused conversation updated webhook tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.153 webwidget triggered webhook checkpoint prepared as `fix(webhooks): deliver webwidget triggered webhooks`; audited Chatwoot `WebhookListener#webwidget_triggered`, `Widget::EventsController`, and `Webhooks::Trigger`. GoChat now has an `EventWebwidgetTriggered` event type, the webhook listener handles and delivers `webwidget_triggered` payloads to subscribed account webhooks, and `WidgetService.TrackEvent` enqueues a webhook job when `webwidget.triggered` is received from the widget SDK. Focused webwidget triggered webhook tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.154 agent bot webhook failure reopen checkpoint prepared as `fix(webhooks): reopen pending conversation on bot failure`; audited Chatwoot `Webhooks::Trigger#update_conversation_status`, which reopens pending conversations when agent bot webhooks fail for message_created/message_updated events. GoChat `AgentBotListener` now injects `ConversationRepo` and reopens pending conversations when agent bot webhook delivery fails for message events, matching Chatwoot behavior. Focused agent bot webhook failure reopen tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.155 agent bot webhook failure activity message checkpoint prepared as `fix(webhooks): create activity on bot failure reopen`; audited Chatwoot `Webhooks::Trigger#create_agent_bot_error_activity`, which creates an activity message when a pending conversation is reopened due to agent bot webhook failure. GoChat `AgentBotListener` now creates an activity message with `message_type=activity` when reopening pending conversations on bot failure, matching Chatwoot behavior. Focused agent bot webhook failure activity message tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.156 agent bot webhook retryable error checkpoint prepared as `fix(webhooks): skip reopen for retryable bot errors`; audited Chatwoot `Webhooks::Trigger#retryable_agent_bot_error?`, which raises `RetryableError` for 429/500 statuses on agent bot webhooks, causing Sidekiq to retry without reopening the conversation. GoChat `AgentBotListener` now checks for retryable 429/500 status codes and skips conversation reopen for those errors, letting the worker retry; non-retryable errors still reopen pending conversations with activity messages. Focused agent bot webhook retryable error tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.157 agent bot keep-pending-on-failure checkpoint prepared as `fix(webhooks): honor bot failure pending setting`; audited Chatwoot `Account` settings `keep_pending_on_bot_failure`, `Webhooks::Trigger#update_conversation_status`, and webhook trigger specs for enabled/disabled behavior. GoChat accounts now expose `keep_pending_on_bot_failure`, and `AgentBotListener` skips reopening pending conversations and skips bot-failure activity messages when that setting is enabled, while preserving P3.156 retryable 429/500 behavior and reopening with activity messages for non-retryable failures when disabled. Focused agent bot listener tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.158 webhook default timeout checkpoint prepared as `fix(webhooks): align default timeout`; audited Chatwoot `Webhooks::Trigger#webhook_timeout` and trigger specs for blank/invalid timeout fallback. GoChat account/API webhook listener and agent bot webhook listener now default outbound webhook HTTP clients to 5 seconds instead of 30 seconds, matching Chatwoot fallback behavior. Focused webhook/agent-bot listener timeout tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.159 webhook timeout config checkpoint prepared as `fix(webhooks): honor timeout config`; audited Chatwoot `GlobalConfig.get_value`, `InstallationConfig#value`, and `Webhooks::Trigger#webhook_timeout`, where positive integer `WEBHOOK_TIMEOUT` values override the default and blank/invalid values fall back to 5 seconds. GoChat now resolves webhook timeout from `installation_configs.name = WEBHOOK_TIMEOUT` through a shared helper used by account/API webhook delivery and agent bot webhook delivery, preserving the 5-second fallback from P3.158. Focused webhook timeout config tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.160 agent bot message-id failure checkpoint prepared as `fix(webhooks): resolve bot failure message`; audited Chatwoot `Webhooks::Trigger#message_id`, `#message`, and `#update_conversation_status`, where agent bot webhook failure handling finds the message from top-level payload `id` and reopens or keeps the message's pending conversation according to account settings. GoChat `AgentBotListener` now falls back from embedded conversation data to top-level/message payload message IDs, loads the message through `MessageRepo`, and applies the same pending reopen / `keep_pending_on_bot_failure` logic to the message conversation. Focused agent bot message-id failure tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.161 agent bot payload shape checkpoint prepared as `fix(webhooks): align bot payload shape`; audited Chatwoot `AgentBotListener#process_message_event`, `#conversation_updated`, and listener specs, where agent bot webhook jobs receive `message.webhook_data` / `conversation.webhook_data` merged with top-level `event`, not a local wrapper containing `data` and `timestamp`. GoChat `AgentBotListener` now sends event data as top-level JSON fields with `event`, `account_id`, and `inbox_id` fallback fields, while preserving P3.160 top-level message-id failure handling. Focused agent bot payload tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.162 agent bot webhook header checkpoint prepared as `fix(webhooks): align bot webhook headers`; audited Chatwoot `Webhooks::Trigger#request_headers`, trigger specs, and `AgentBotListener#process_webhook_bot_event`, where agent bot webhooks go through the generic webhook trigger with delivery id and timestamp-prefixed HMAC signature headers. GoChat `AgentBotListener` now sends `Accept: application/json`, `X-Chatwoot-Delivery`, and `X-Chatwoot-Timestamp`/`X-Chatwoot-Signature=sha256=` when the bot has a secret, and removes the local `X-Signature`/`X-Agent-Bot-*` delivery headers, while preserving P3.161 top-level payload shape. Focused agent bot header/signature tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.163 assigned agent bot delivery checkpoint prepared as `fix(webhooks): notify assigned bot`; audited Chatwoot `AgentBotListener#agent_bots_for` and listener specs, where webhook events are delivered to both `conversation.assignee_agent_bot` and the inbox active agent bot, compacted and de-duplicated. GoChat conversations now include `assignee_agent_bot_id`, and `AgentBotListener` resolves the conversation's assigned bot from event payload or DB, appends it to active inbox bot delivery, and de-duplicates when the assigned bot is also the inbox bot. Focused assigned/inbox agent bot delivery tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.164 agent bot dispatcher registration checkpoint prepared as `fix(webhooks): register bot listener`; audited Chatwoot `AgentBotListener` Wisper event methods and GoChat channel dispatcher registration. GoChat `AgentBotListener` now implements `channel.EventListener`, maps channel events (`message.created/updated`, `conversation.updated/opened/resolved`, `webwidget.triggered`) to Chatwoot agent bot event names, and is registered in bootstrap so events dispatched through the channel dispatcher reach agent bot webhook delivery. Focused dispatcher-adapter agent bot tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + + +- 2026-06-09: P3.165 agent bot opened/resolved event parity prepared as `fix(webhooks): align opened resolved events`; audited Chatwoot `AgentBotListener#conversation_opened` and `#conversation_resolved`, which use `__method__.to_s` as the event name, producing `conversation_opened` and `conversation_resolved` respectively. GoChat `agentBotEventName` previously mapped both to `conversation_status_changed`, now correctly returns `conversation_opened` and `conversation_resolved`. Focused event name mapping tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. + +- 2026-06-09: P3.166 agent bot message sendable parity prepared as `fix(webhooks): align bot message sendable`; audited Chatwoot `MessageFilterHelpers#webhook_sendable?` which returns true only for `incoming`/`outgoing`/`template` message types, and `AgentBotListener#message_created`/`#message_updated` which calls `webhook_sendable?` before processing. GoChat `AgentBotListener.OnEvent` now checks message type from event data and skips delivery for `activity`, `csat`, and other non-sendable message types, matching Chatwoot behavior. Focused activity-message-skip tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index e3691a1d..b5517a44 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -394,6 +394,7 @@ func Bootstrap(env string) (*App, error) { // Register listeners on the channel dispatcher (keyed by Name(), not channel type) channelDispatcher.Register(fbListener) channelDispatcher.Register(igListener) + channelDispatcher.Register(channel.NewWebhookListenerWithDB(db)) // Create Facebook webhook handler (Gin HTTP handler for FB/IG webhook endpoints) facebookWebhookHandler := webhook.NewFacebookWebhookHandler(fbProvider, igProvider, db, channelDispatcher) facebookWebhookHandler.WithWorkerPool(workerPool) @@ -610,7 +611,8 @@ func Bootstrap(env string) (*App, error) { // M12: AgentBot services (AgentBot + AgentBotInbox + Listener) agentBotService := service.NewAgentBotService(agentBotRepo) agentBotInboxService := service.NewAgentBotInboxService(agentBotInboxRepo, agentBotRepo) - agentBotListener := service.NewAgentBotListener(agentBotInboxRepo, agentBotRepo) + agentBotListener := service.NewAgentBotListener(agentBotInboxRepo, agentBotRepo, conversationRepo, messageRepo) + channelDispatcher.Register(agentBotListener) // Platform: InstallationConfig service (global key-value config for super-admin) installationConfigService := service.NewInstallationConfigService(installationConfigRepo) @@ -876,8 +878,6 @@ func Bootstrap(env string) (*App, error) { // SummaryReport handler (read-only reporting resource — agent/team/inbox/label summaries) SummaryReport: v1.NewSummaryReportHandler(summaryReportService), } - _ = agentBotListener // M12: subscribed to message events via service calls - // Step 10: Setup Gin router + middleware chain // (ref: Chatwoot Rails middleware stack in config/application.rb) gin.SetMode(cfg.Server.Mode) diff --git a/internal/automation/action_delivery.go b/internal/automation/action_delivery.go index d5b341c4..63a58eaa 100644 --- a/internal/automation/action_delivery.go +++ b/internal/automation/action_delivery.go @@ -40,6 +40,7 @@ type AutomationWebhookRequest struct { AccountID uint ConversationID uint EventName string + WebhookEvent string URL string Payload map[string]interface{} } @@ -66,6 +67,13 @@ type HTTPAutomationWebhookDeliverer struct { retryDelay time.Duration } +func webhookEventName(req AutomationWebhookRequest) string { + if strings.TrimSpace(req.WebhookEvent) != "" { + return req.WebhookEvent + } + return fmt.Sprintf("automation_event.%s", req.EventName) +} + func NewHTTPAutomationWebhookDeliverer(client *http.Client, maxAttempts int, retryDelay time.Duration) *HTTPAutomationWebhookDeliverer { if client == nil { client = &http.Client{Timeout: defaultActionDeliveryTimeout} @@ -101,7 +109,7 @@ func (d *HTTPAutomationWebhookDeliverer) DeliverWebhook(ctx context.Context, req 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)) + httpReq.Header.Set("X-Webhook-Event", webhookEventName(req)) resp, err := d.client.Do(httpReq) cancel() diff --git a/internal/automation/action_delivery_worker.go b/internal/automation/action_delivery_worker.go index 426c47a8..334af7f3 100644 --- a/internal/automation/action_delivery_worker.go +++ b/internal/automation/action_delivery_worker.go @@ -14,6 +14,7 @@ type automationWebhookDeliveryJob struct { AccountID uint `json:"account_id"` ConversationID uint `json:"conversation_id"` EventName string `json:"event_name"` + WebhookEvent string `json:"webhook_event,omitempty"` URL string `json:"url"` Payload map[string]interface{} `json:"payload"` } @@ -68,6 +69,7 @@ func (r *actionDeliveryJobRunner) performWebhookDelivery(ctx context.Context, jo AccountID: payload.AccountID, ConversationID: payload.ConversationID, EventName: payload.EventName, + WebhookEvent: payload.WebhookEvent, URL: payload.URL, Payload: payload.Payload, }) diff --git a/internal/automation/action_service.go b/internal/automation/action_service.go index 8a18f695..0dbf03d1 100644 --- a/internal/automation/action_service.go +++ b/internal/automation/action_service.go @@ -145,7 +145,7 @@ func (s *ActionService) ExecuteWithResult(ctx context.Context, accountID uint, c err = s.handleRemoveAssignedTeam(ctx, accountID, conversationID) indexConversation = true case "send_webhook_event": - deliveryResult, err = s.handleSendWebhookEvent(ctx, accountID, conversationID, resolvedAction) + deliveryResult, err = s.handleSendWebhookEvent(ctx, accountID, conversationID, resolvedAction, source) case "mute_conversation": err = s.handleMuteConversation(ctx, accountID, conversationID) indexConversation = true @@ -412,7 +412,7 @@ func (s *ActionService) handleRemoveAssignedTeam(ctx context.Context, accountID, // handleSendWebhookEvent sends a webhook event for the conversation. // 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) { +func (s *ActionService) handleSendWebhookEvent(ctx context.Context, accountID, conversationID uint, action Action, source ActionSource) (ActionDeliveryResult, error) { url := firstStringParam(action.ActionParams, "url", "webhook_url") if url == "" { values := extractStringSlice(action.ActionParams, "values") @@ -424,7 +424,8 @@ func (s *ActionService) handleSendWebhookEvent(ctx context.Context, accountID, c 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) + webhookEvent := webhookEventForActionSource(source, eventName) + payload, err := s.buildAutomationWebhookPayload(ctx, accountID, conversationID, eventName, webhookEvent) if err != nil { return ActionDeliveryResult{DeliveryType: "webhook", Target: url}, err } @@ -433,6 +434,7 @@ func (s *ActionService) handleSendWebhookEvent(ctx context.Context, accountID, c AccountID: accountID, ConversationID: conversationID, EventName: eventName, + WebhookEvent: webhookEvent, URL: url, Payload: payload, }, worker.WithQueue("automation"), worker.WithMaxAttempts(defaultActionDeliveryAttempts)) @@ -445,11 +447,19 @@ func (s *ActionService) handleSendWebhookEvent(ctx context.Context, accountID, c AccountID: accountID, ConversationID: conversationID, EventName: eventName, + WebhookEvent: webhookEvent, URL: url, Payload: payload, }) } +func webhookEventForActionSource(source ActionSource, eventName string) string { + if source == ActionSourceMacro { + return "macro.executed" + } + return fmt.Sprintf("automation_event.%s", eventName) +} + // handleMuteConversation mutes notifications for the conversation. // Reference: Chatwoot ActionService#mute_conversation delegates to Conversation#mute!. func (s *ActionService) handleMuteConversation(ctx context.Context, accountID, conversationID uint) error { @@ -1040,7 +1050,7 @@ func (s *ActionService) consumeTranscriptEmailQuota(ctx context.Context, account return true, nil } -func (s *ActionService) buildAutomationWebhookPayload(ctx context.Context, accountID, conversationID uint, eventName string) (map[string]interface{}, error) { +func (s *ActionService) buildAutomationWebhookPayload(ctx context.Context, accountID, conversationID uint, eventName string, webhookEvent string) (map[string]interface{}, error) { var conversation model.Conversation if err := s.db.DB().WithContext(ctx). Where("id = ? AND account_id = ?", conversationID, accountID). @@ -1067,7 +1077,7 @@ func (s *ActionService) buildAutomationWebhookPayload(ctx context.Context, accou createdAt := conversation.CreatedAt.Unix() updatedAt := float64(conversation.UpdatedAt.UnixNano()) / float64(time.Second) payload := map[string]interface{}{ - "event": fmt.Sprintf("automation_event.%s", eventName), + "event": webhookEvent, "additional_attributes": jsonObject(conversation.AdditionalAttributes), "custom_attributes": jsonObject(conversation.CustomAttributes), "id": displayID, diff --git a/internal/automation/csat_survey_listener_test.go b/internal/automation/csat_survey_listener_test.go index a979b4a6..e2be86d6 100644 --- a/internal/automation/csat_survey_listener_test.go +++ b/internal/automation/csat_survey_listener_test.go @@ -37,7 +37,7 @@ func TestCsatSurveyListener_ResolvedConversationSendsOneSurveyMessage(t *testing if len(messages) != 1 { t.Fatalf("expected exactly one CSAT message, got %d", len(messages)) } - if messages[0].MessageType != "template" { + if messages[0].MessageType != string(model.MessageTypeTemplate) { t.Fatalf("expected template message type, got %q", messages[0].MessageType) } if messages[0].Content != "Rate this chat" { @@ -87,6 +87,206 @@ func TestCsatSurveyListener_ResolvedConversationSkipsDisabledInbox(t *testing.T) } } +func TestCsatSurveyListener_ResolvedTweetConversationSkipsSurvey(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, _ := seedTestAccount(db, t) + conversation := seedCsatListenerConversation(t, db, accountID, true) + if err := setCsatConversationChannel(t, db, conversation, "twitter", ""); err != nil { + t.Fatalf("failed to set twitter channel: %v", err) + } + conversation.AdditionalAttributes = datatypes.JSON(`{"type":"tweet"}`) + if err := db.Save(conversation).Error; err != nil { + t.Fatalf("failed to mark conversation as tweet: %v", err) + } + listener := NewCsatSurveyListener(dbProvider) + event := &channel.ChannelEvent{Type: channel.EventConversationResolved, ConversationID: conversation.ID} + + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("expected no listener error, got %v", err) + } + + var messageCount int64 + if err := db.Model(&model.Message{}).Where("conversation_id = ?", conversation.ID).Count(&messageCount).Error; err != nil { + t.Fatalf("failed to count messages: %v", err) + } + if messageCount != 0 { + t.Fatalf("expected tweet conversation to skip CSAT messages, got %d", messageCount) + } +} + +func TestCsatSurveyListener_ResolvedWebWidgetConversationIgnoresAllowMessagesAfterResolved(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, _ := seedTestAccount(db, t) + conversation := seedCsatListenerConversation(t, db, accountID, true) + if err := db.Model(&model.Inbox{}).Where("id = ?", conversation.InboxID).Update("allow_messages_after_resolved", false).Error; err != nil { + t.Fatalf("failed to update resolved-message setting: %v", err) + } + listener := NewCsatSurveyListener(dbProvider) + event := &channel.ChannelEvent{Type: channel.EventConversationResolved, ConversationID: conversation.ID} + + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("expected no listener error, got %v", err) + } + + var csatMessageCount int64 + if err := db.Model(&model.Message{}).Where("conversation_id = ? AND content_type = ?", conversation.ID, "input_csat").Count(&csatMessageCount).Error; err != nil { + t.Fatalf("failed to count csat messages: %v", err) + } + if csatMessageCount != 1 { + t.Fatalf("expected web widget CSAT survey despite resolved-message setting, got %d", csatMessageCount) + } + var activityCount int64 + if err := db.Model(&model.Message{}).Where("conversation_id = ? AND message_type = ?", conversation.ID, string(model.MessageTypeActivity)).Count(&activityCount).Error; err != nil { + t.Fatalf("failed to count activity messages: %v", err) + } + if activityCount != 0 { + t.Fatalf("expected no web widget CSAT activity, got %d", activityCount) + } +} + +func TestCsatSurveyListener_ResolvedWhatsAppConversationCreatesActivityWhenMessagingWindowClosed(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, _ := seedTestAccount(db, t) + conversation := seedCsatListenerConversation(t, db, accountID, true) + if err := setCsatConversationChannel(t, db, conversation, "whatsapp", ""); err != nil { + t.Fatalf("failed to set whatsapp channel: %v", err) + } + listener := NewCsatSurveyListener(dbProvider) + event := &channel.ChannelEvent{Type: channel.EventConversationResolved, ConversationID: conversation.ID} + + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("expected no listener error, got %v", err) + } + + var csatMessageCount int64 + if err := db.Model(&model.Message{}).Where("conversation_id = ? AND content_type = ?", conversation.ID, "input_csat").Count(&csatMessageCount).Error; err != nil { + t.Fatalf("failed to count csat messages: %v", err) + } + if csatMessageCount != 0 { + t.Fatalf("expected no CSAT survey outside messaging window, got %d", csatMessageCount) + } + var activity model.Message + if err := db.Where("conversation_id = ? AND message_type = ?", conversation.ID, string(model.MessageTypeActivity)).First(&activity).Error; err != nil { + t.Fatalf("expected CSAT not-sent activity message, got %v", err) + } + if activity.Content != csatNotSentDueToMessagingWindowMessage { + t.Fatalf("unexpected activity content: %q", activity.Content) + } +} + +func TestCsatSurveyListener_ResolvedWhatsAppConversationSendsSurveyWithinMessagingWindow(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, _ := seedTestAccount(db, t) + conversation := seedCsatListenerConversation(t, db, accountID, true) + if err := setCsatConversationChannel(t, db, conversation, "whatsapp", ""); err != nil { + t.Fatalf("failed to set whatsapp channel: %v", err) + } + incoming := &model.Message{ + ConversationID: conversation.ID, + AccountID: conversation.AccountID, + InboxID: conversation.InboxID, + Content: "Recent customer message", + ContentType: "text", + MessageType: string(model.MessageTypeIncoming), + Status: "sent", + } + if err := db.Create(incoming).Error; err != nil { + t.Fatalf("failed to create incoming message: %v", err) + } + listener := NewCsatSurveyListener(dbProvider) + event := &channel.ChannelEvent{Type: channel.EventConversationResolved, ConversationID: conversation.ID} + + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("expected no listener error, got %v", err) + } + + var csatMessageCount int64 + if err := db.Model(&model.Message{}).Where("conversation_id = ? AND content_type = ?", conversation.ID, "input_csat").Count(&csatMessageCount).Error; err != nil { + t.Fatalf("failed to count csat messages: %v", err) + } + if csatMessageCount != 1 { + t.Fatalf("expected one CSAT survey inside messaging window, got %d", csatMessageCount) + } +} + +func TestCsatSurveyListener_ResolvedAPIConversationUsesNestedAgentReplyWindow(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, _ := seedTestAccount(db, t) + conversation := seedCsatListenerConversation(t, db, accountID, true) + if err := setCsatConversationChannel(t, db, conversation, "api", `{"additional_attributes":{"agent_reply_time_window":2}}`); err != nil { + t.Fatalf("failed to set api channel: %v", err) + } + oldIncoming := &model.Message{ + ConversationID: conversation.ID, + AccountID: conversation.AccountID, + InboxID: conversation.InboxID, + Content: "Old API customer message", + ContentType: "text", + MessageType: string(model.MessageTypeIncoming), + Status: "sent", + } + if err := db.Create(oldIncoming).Error; err != nil { + t.Fatalf("failed to create incoming message: %v", err) + } + oldCreatedAt := time.Now().Add(-3 * time.Hour) + if err := db.Model(oldIncoming).Update("created_at", oldCreatedAt).Error; err != nil { + t.Fatalf("failed to age incoming message: %v", err) + } + listener := NewCsatSurveyListener(dbProvider) + event := &channel.ChannelEvent{Type: channel.EventConversationResolved, ConversationID: conversation.ID} + + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("expected no listener error, got %v", err) + } + + var activity model.Message + if err := db.Where("conversation_id = ? AND message_type = ?", conversation.ID, string(model.MessageTypeActivity)).First(&activity).Error; err != nil { + t.Fatalf("expected API CSAT not-sent activity message, got %v", err) + } + if activity.Content != csatNotSentDueToMessagingWindowMessage { + t.Fatalf("unexpected activity content: %q", activity.Content) + } +} + +func TestCsatSurveyListener_ResolvedAPIConversationWithoutReplyWindowSendsSurvey(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, _ := seedTestAccount(db, t) + conversation := seedCsatListenerConversation(t, db, accountID, true) + if err := setCsatConversationChannel(t, db, conversation, "api", `{"additional_attributes":{"source":"frontend"}}`); err != nil { + t.Fatalf("failed to set api channel: %v", err) + } + listener := NewCsatSurveyListener(dbProvider) + event := &channel.ChannelEvent{Type: channel.EventConversationResolved, ConversationID: conversation.ID} + + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("expected no listener error, got %v", err) + } + + var csatMessageCount int64 + if err := db.Model(&model.Message{}).Where("conversation_id = ? AND content_type = ?", conversation.ID, "input_csat").Count(&csatMessageCount).Error; err != nil { + t.Fatalf("failed to count csat messages: %v", err) + } + if csatMessageCount != 1 { + t.Fatalf("expected API CSAT survey without reply window, got %d", csatMessageCount) + } +} + +func setCsatConversationChannel(t *testing.T, db *gorm.DB, conversation *model.Conversation, channelType string, channelConfig string) error { + t.Helper() + if err := db.Model(&model.Inbox{}).Where("id = ?", conversation.InboxID).Updates(map[string]any{"channel_type": channelType, "channel_config": channelConfig}).Error; err != nil { + return err + } + conversation.ChannelType = channelType + conversation.Channel = channelType + return db.Save(conversation).Error +} + func TestCsatSurveyListener_ResolvedConversationQueuesDurableSurveySend(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() @@ -151,7 +351,7 @@ func TestCsatSurveyListener_MessageUpdatedBuildsResponse(t *testing.T) { AccountID: conversation.AccountID, InboxID: conversation.InboxID, ContentType: "input_csat", - MessageType: "template", + MessageType: string(model.MessageTypeTemplate), Status: "sent", ContentAttributes: datatypes.JSON( `{"submitted_values":{"csat_survey_response":{"rating":5,"feedback_message":"Great"}}}`, diff --git a/internal/automation/csat_survey_service.go b/internal/automation/csat_survey_service.go index 186cbf9d..ba0d4a44 100644 --- a/internal/automation/csat_survey_service.go +++ b/internal/automation/csat_survey_service.go @@ -91,6 +91,15 @@ func (s *CsatSurveyService) GetByID(ctx context.Context, id uint) (*CsatSurveyRe return &resp, nil } +// GetByIDForAccount retrieves a CSAT survey response scoped to an account. +func (s *CsatSurveyService) GetByIDForAccount(ctx context.Context, accountID uint, id uint) (*CsatSurveyResponse, error) { + var resp CsatSurveyResponse + if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).First(&resp).Error; err != nil { + return nil, err + } + return &resp, nil +} + // GetByConversationUUID retrieves a CSAT survey response by conversation UUID (for public access). // Reference: Chatwoot GET /public/api/v1/csat_survey/:id — uses conversation UUID func (s *CsatSurveyService) GetByConversationUUID(ctx context.Context, conversationUUID string) (*CsatSurveyResponse, error) { @@ -150,6 +159,9 @@ func (s *CsatSurveyService) SendSurveyForConversation(ctx context.Context, conve if conversation.Status != "resolved" { return nil, nil } + if isTweetConversation(conversation) { + return nil, nil + } var inbox model.Inbox if err := s.db.DB().WithContext(ctx).First(&inbox, conversation.InboxID).Error; err != nil { @@ -169,6 +181,21 @@ func (s *CsatSurveyService) SendSurveyForConversation(ctx context.Context, conve if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } + if !conversationCanReceiveCsatSurvey(ctx, s.db.DB(), conversation, &inbox) { + message := &model.Message{ + ConversationID: conversation.ID, + AccountID: conversation.AccountID, + InboxID: conversation.InboxID, + Content: csatNotSentDueToMessagingWindowMessage, + ContentType: "text", + MessageType: string(model.MessageTypeActivity), + Status: "sent", + } + if err := s.db.DB().WithContext(ctx).Create(message).Error; err != nil { + return nil, err + } + return message, nil + } config := csatConfigMap(inbox.CsatConfig) content, _ := config["message"].(string) @@ -186,7 +213,7 @@ func (s *CsatSurveyService) SendSurveyForConversation(ctx context.Context, conve InboxID: conversation.InboxID, Content: content, ContentType: "input_csat", - MessageType: "template", + MessageType: string(model.MessageTypeTemplate), Status: "sent", ContentAttributes: attrs, } @@ -197,6 +224,112 @@ func (s *CsatSurveyService) SendSurveyForConversation(ctx context.Context, conve return message, nil } +const csatNotSentDueToMessagingWindowMessage = "CSAT survey not sent due to outgoing message restrictions" + +func conversationCanReceiveCsatSurvey(ctx context.Context, db *gorm.DB, conversation *model.Conversation, inbox *model.Inbox) bool { + if conversation == nil || inbox == nil { + return false + } + window := csatMessagingWindow(inbox) + if window <= 0 { + return true + } + + var lastIncoming model.Message + err := db.WithContext(ctx). + Where("conversation_id = ? AND account_id = ? AND message_type = ?", conversation.ID, conversation.AccountID, string(model.MessageTypeIncoming)). + Order("created_at DESC, id DESC"). + First(&lastIncoming).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false + } + if err != nil { + applogger.L().Warnf("csat: failed to inspect last incoming message for conversation %d: %v", conversation.ID, err) + return false + } + return time.Now().Before(lastIncoming.CreatedAt.Add(window)) +} + +func csatMessagingWindow(inbox *model.Inbox) time.Duration { + if inbox == nil { + return 0 + } + switch strings.ToLower(strings.TrimSpace(inbox.ChannelType)) { + case "api", "channel::api": + return apiAgentReplyWindow(inbox.ChannelConfig) + case "facebook", "channel::facebookpage", "instagram", "channel::instagram": + return 24 * time.Hour + case "tiktok", "channel::tiktok": + return 48 * time.Hour + case "whatsapp", "channel::whatsapp": + return 24 * time.Hour + case "twilio_sms", "channel::twiliosms": + if strings.EqualFold(inboxChannelConfigString(inbox.ChannelConfig, "medium"), "whatsapp") { + return 24 * time.Hour + } + } + return 0 +} + +func isTweetConversation(conversation *model.Conversation) bool { + if conversation == nil { + return false + } + channelType := strings.ToLower(strings.TrimSpace(conversation.ChannelType)) + if channelType != "twitter" && channelType != "channel::twitterprofile" { + return false + } + attrs := jsonMap(conversation.AdditionalAttributes) + conversationType, _ := attrs["type"].(string) + return strings.EqualFold(strings.TrimSpace(conversationType), "tweet") +} + +func apiAgentReplyWindow(rawConfig string) time.Duration { + hours := inboxChannelConfigInt(rawConfig, "additional_attributes", "agent_reply_time_window") + if hours <= 0 { + hours = inboxChannelConfigInt(rawConfig, "agent_reply_time_window") + } + if hours <= 0 { + return 0 + } + return time.Duration(hours) * time.Hour +} + +func inboxChannelConfigString(rawConfig, key string) string { + config := map[string]any{} + if strings.TrimSpace(rawConfig) == "" { + return "" + } + if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { + return "" + } + value, _ := config[key].(string) + return strings.TrimSpace(value) +} + +func inboxChannelConfigInt(rawConfig string, keys ...string) int { + config := map[string]any{} + if strings.TrimSpace(rawConfig) == "" { + return 0 + } + if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { + return 0 + } + var value any = config + for _, key := range keys { + current, ok := value.(map[string]any) + if !ok { + return 0 + } + value = current[key] + } + parsed, ok := intValue(value) + if !ok { + return 0 + } + return parsed +} + func (s *CsatSurveyService) indexCsatSearchDocuments(ctx context.Context, message *model.Message, conversation *model.Conversation) { if s.searchIndexer == nil { return @@ -479,7 +612,7 @@ func (s *CsatSurveyService) Metrics(ctx context.Context, accountID uint, filter sentMessages = sentMessages.Where("created_at >= ?", *filter.Since) } if filter.Until != nil { - sentMessages = sentMessages.Where("created_at <= ?", *filter.Until) + sentMessages = sentMessages.Where("created_at < ?", *filter.Until) } var sentCount int64 if err := sentMessages.Count(&sentCount).Error; err != nil { @@ -511,7 +644,7 @@ func (s *CsatSurveyService) csatResponsesQuery(ctx context.Context, accountID ui query = query.Where("csat_survey_responses.created_at >= ?", *filter.Since) } if filter.Until != nil { - query = query.Where("csat_survey_responses.created_at <= ?", *filter.Until) + query = query.Where("csat_survey_responses.created_at < ?", *filter.Until) } if filter.InboxID != nil || filter.TeamID != nil { query = query.Joins("JOIN conversations ON conversations.id = csat_survey_responses.conversation_id") diff --git a/internal/automation/macro_service_test.go b/internal/automation/macro_service_test.go index 8628a125..bcbd32c3 100644 --- a/internal/automation/macro_service_test.go +++ b/internal/automation/macro_service_test.go @@ -297,3 +297,47 @@ func TestMacroService_ExecuteForDisplayIDsCreatesAttachmentMessages(t *testing.T t.Fatalf("unexpected attachment: %#v", attachment) } } + +func TestMacroService_ExecuteForDisplayIDsSendsMacroWebhookEvent(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, userID := seedTestAccount(db, t) + inboxID := seedTestInbox(db, t, accountID) + contactID := seedTestContact(db, t, accountID) + displayID := uint(718) + conversationID := seedTestConversationWithDetails(db, t, accountID, inboxID, contactID, "open", "low", "web", 0) + if err := db.Model(&model.Conversation{}).Where("id = ?", conversationID).Update("display_id", displayID).Error; err != nil { + t.Fatalf("set display id: %v", err) + } + webhook := &recordingWebhookDeliverer{} + restore := setAutomationActionDeliverersForTest(webhook, &recordingTranscriptDeliverer{}) + defer restore() + + macro := &Macro{ + AccountID: accountID, + Name: "webhook macro", + Actions: Actions{{ActionName: "send_webhook_event", ActionParams: map[string]interface{}{"url": "https://hooks.example/macro"}}}, + Visibility: MacroVisibilityGlobal, + CreatedByID: userID, + UpdatedByID: userID, + } + svc := NewMacroService(dbProvider) + if err := svc.Create(context.Background(), macro); err != nil { + t.Fatalf("create macro: %v", err) + } + + if err := svc.ExecuteForDisplayIDs(context.Background(), accountID, macro.ID, []uint{displayID}, userID); err != nil { + t.Fatalf("execute macro: %v", err) + } + + if len(webhook.requests) != 1 { + t.Fatalf("expected one macro webhook request, got %d", len(webhook.requests)) + } + req := webhook.requests[0] + if req.URL != "https://hooks.example/macro" || req.WebhookEvent != "macro.executed" { + t.Fatalf("unexpected macro webhook request: %#v", req) + } + if req.Payload["event"] != "macro.executed" { + t.Fatalf("expected macro.executed payload event, got %#v", req.Payload["event"]) + } +} diff --git a/internal/channel/event.go b/internal/channel/event.go index 104cf203..d9b7a378 100644 --- a/internal/channel/event.go +++ b/internal/channel/event.go @@ -47,6 +47,9 @@ const ( // Webhook events EventWebhookReceived EventType = "webhook.received" + // Widget events + EventWebwidgetTriggered EventType = "webwidget.triggered" + // Agent events EventAgentAdded EventType = "agent.added" EventAgentRemoved EventType = "agent.removed" diff --git a/internal/channel/listener.go b/internal/channel/listener.go index 29326d90..b9246754 100644 --- a/internal/channel/listener.go +++ b/internal/channel/listener.go @@ -1,13 +1,23 @@ package channel import ( + "bytes" "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" + "io" "net/http" "time" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/gochat/gochat/internal/webhookutil" applogger "github.com/gochat/gochat/pkg/logger" + "github.com/google/uuid" + "gorm.io/gorm" ) // =========================== @@ -220,17 +230,24 @@ func (b *BaseListener) extractInbox(event *ChannelEvent) (*model.Inbox, error) { type WebhookListener struct { BaseListener httpClient *http.Client + db *gorm.DB } // NewWebhookListener creates a new webhook listener with default HTTP client. func NewWebhookListener() *WebhookListener { return &WebhookListener{ - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, + httpClient: &http.Client{Timeout: webhookutil.Timeout(context.Background(), nil)}, } } +// NewWebhookListenerWithDB creates a webhook listener that can resolve API inbox webhook settings. +func NewWebhookListenerWithDB(db *gorm.DB) *WebhookListener { + listener := NewWebhookListener() + listener.db = db + listener.httpClient = &http.Client{Timeout: webhookutil.Timeout(context.Background(), db)} + return listener +} + // NewWebhookListenerWithClient creates a webhook listener with a custom HTTP client (for testing). func NewWebhookListenerWithClient(client *http.Client) *WebhookListener { return &WebhookListener{ @@ -238,6 +255,12 @@ func NewWebhookListenerWithClient(client *http.Client) *WebhookListener { } } +// WithDB wires persistence access for channel-specific webhook settings. +func (w *WebhookListener) WithDB(db *gorm.DB) *WebhookListener { + w.db = db + return w +} + // Name returns the listener identifier. func (w *WebhookListener) Name() string { return "webhook" @@ -248,7 +271,12 @@ func (w *WebhookListener) Name() string { func (w *WebhookListener) OnEvent(ctx context.Context, event *ChannelEvent) error { switch event.Type { case EventMessageCreated, EventMessageUpdated, - EventConversationCreated, EventConversationUpdated: + EventConversationCreated, EventConversationUpdated, + EventConversationOpened, EventConversationResolved, + EventConversationTypingOn, EventConversationTypingOff, + EventContactCreated, EventContactUpdated, + EventInboxCreated, EventInboxUpdated, + EventWebwidgetTriggered: return w.deliverWebhook(ctx, event) default: // Not a webhook-relevant event — skip @@ -259,28 +287,304 @@ func (w *WebhookListener) OnEvent(ctx context.Context, event *ChannelEvent) erro // deliverWebhook constructs and sends the webhook payload. // Reference: Chatwoot's WebhookService.process_event — builds payload, signs it, POSTs to URL func (w *WebhookListener) deliverWebhook(ctx context.Context, event *ChannelEvent) error { - // Build webhook payload structure - _ = map[string]interface{}{ - "event": string(event.Type), + if event == nil { + return nil + } + payload := webhookEventPayload(event) + if shouldSkipWebhookEvent(event, payload) { + return nil + } + // Account webhooks: log errors but continue. + if err := w.deliverAccountWebhooks(ctx, event, payload); err != nil { + applogger.L().Warnf("WebhookListener: account webhook delivery error for event=%s account=%d: %v", event.Type, event.AccountID, err) + } + // API inbox webhooks: on failure for message_created/updated, mark message as failed. + if err := w.deliverAPIInboxWebhook(ctx, event, payload); err != nil { + applogger.L().Warnf("WebhookListener: api inbox webhook delivery error for event=%s inbox=%d: %v", event.Type, event.InboxID, err) + if isMessageWebhookEvent(event.Type) { + if markErr := w.markMessageFailedFromEvent(ctx, event); markErr != nil { + applogger.L().Warnf("WebhookListener: failed to mark message as failed: %v", markErr) + } + } + } + applogger.L().Infof("WebhookListener: delivered webhook for event=%s account=%d inbox=%d", event.Type, event.AccountID, event.InboxID) + return nil +} + +func isMessageWebhookEvent(eventType EventType) bool { + return eventType == EventMessageCreated || eventType == EventMessageUpdated +} + +func (w *WebhookListener) markMessageFailedFromEvent(ctx context.Context, event *ChannelEvent) error { + if w == nil || w.db == nil || event == nil { + return nil + } + msg, ok := extractMessageFromPayloadData(event.Data) + if !ok { + return nil + } + var messageID uint + switch id := msg["id"].(type) { + case float64: + messageID = uint(id) + case uint: + messageID = id + case int: + messageID = uint(id) + default: + return nil + } + if messageID == 0 { + return nil + } + return w.db.WithContext(ctx).Model(&model.Message{}).Where("id = ?", messageID).Update("status", "failed").Error +} + +func (w *WebhookListener) deliverAccountWebhooks(ctx context.Context, event *ChannelEvent, payload map[string]interface{}) error { + if w == nil || w.db == nil || event == nil || event.AccountID == 0 { + return nil + } + eventName, _ := payload["event"].(string) + if eventName == "" { + return nil + } + var subscriptions []model.WebhookSubscription + if err := w.db.WithContext(ctx). + Where("account_id = ? AND active = ?", event.AccountID, true). + Order("id ASC"). + Find(&subscriptions).Error; err != nil { + return fmt.Errorf("load account webhook subscriptions: %w", err) + } + if len(subscriptions) == 0 { + return nil + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal account webhook payload: %w", err) + } + for _, subscription := range subscriptions { + if subscription.URL == "" || !subscription.IsEventSubscribed(eventName) { + continue + } + if err := w.postWebhook(ctx, subscription.URL, "account_webhook", subscription.Secret, payloadJSON); err != nil { + return err + } + } + return nil +} + +func (w *WebhookListener) deliverAPIInboxWebhook(ctx context.Context, event *ChannelEvent, payload map[string]interface{}) error { + if w == nil || w.db == nil || event == nil || event.InboxID == 0 || !isAPIInboxWebhookEvent(event) { + return nil + } + var channelAPI channelmodel.ChannelAPI + if err := w.db.WithContext(ctx).Where("inbox_id = ?", event.InboxID).First(&channelAPI).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return fmt.Errorf("load api channel webhook settings: %w", err) + } + if channelAPI.WebhookURL == "" { + return nil + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal api inbox webhook payload: %w", err) + } + return w.postWebhook(ctx, channelAPI.WebhookURL, "api_inbox_webhook", channelAPI.Secret, payloadJSON) +} + +func (w *WebhookListener) postWebhook(ctx context.Context, url, webhookType, secret string, payloadJSON []byte) error { + client := w.httpClient + if client == nil { + client = &http.Client{Timeout: webhookutil.Timeout(ctx, w.db)} + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payloadJSON)) + if err != nil { + return fmt.Errorf("build webhook request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Chatwoot-Webhook-Type", webhookType) + req.Header.Set("X-Chatwoot-Delivery", uuid.NewString()) + if secret != "" { + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + req.Header.Set("X-Chatwoot-Timestamp", timestamp) + req.Header.Set("X-Chatwoot-Signature", signWebhookPayload(payloadJSON, secret, timestamp)) + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("send webhook: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("webhook endpoint returned status %d: %s", resp.StatusCode, string(body)) + } + return nil +} + +func webhookEventPayload(event *ChannelEvent) map[string]interface{} { + payload := map[string]interface{}{ + "event": chatwootWebhookEventName(event.Type), "account_id": event.AccountID, "inbox_id": event.InboxID, "timestamp": event.Timestamp, "data": event.Data, } + if changed := normalizedChangedAttributes(event.Data["changed_attributes"]); len(changed) > 0 { + payload["changed_attributes"] = changed + } + return payload +} - // In production, this would: - // 1. Look up webhook URLs from account/inbox configuration - // 2. Sign the payload with HMAC - // 3. POST to each configured URL with retry logic - // For now, log the webhook delivery intent - applogger.L().Infof("WebhookListener: delivering webhook for event=%s account=%d inbox=%d", - event.Type, event.AccountID, event.InboxID) +func isAPIInboxWebhookEvent(event *ChannelEvent) bool { + if event.Channel == ChannelAPI { + return true + } + if inbox, ok := event.Data["inbox"].(*model.Inbox); ok && (inbox.ChannelType == "api" || inbox.ChannelType == string(model.InboxChannelTypeAPI)) { + return true + } + if inbox, ok := event.Data["inbox"].(model.Inbox); ok && (inbox.ChannelType == "api" || inbox.ChannelType == string(model.InboxChannelTypeAPI)) { + return true + } + return false +} - // Placeholder: the actual HTTP delivery would be implemented when - // the webhook URL configuration and HMAC signing modules are ready. - // See internal/channel/webhook.go for the WebhookHandler that receives these. +func chatwootWebhookEventName(eventType EventType) string { + switch eventType { + case EventMessageCreated: + return "message_created" + case EventMessageUpdated: + return "message_updated" + case EventConversationCreated: + return "conversation_created" + case EventConversationUpdated: + return "conversation_updated" + case EventConversationOpened, EventConversationResolved: + return "conversation_status_changed" + case EventConversationTypingOn: + return "conversation_typing_on" + case EventConversationTypingOff: + return "conversation_typing_off" + case EventContactCreated: + return "contact_created" + case EventContactUpdated: + return "contact_updated" + case EventInboxCreated: + return "inbox_created" + case EventInboxUpdated: + return "inbox_updated" + case EventWebwidgetTriggered: + return "webwidget_triggered" + default: + return string(eventType) + } +} - return nil +func shouldSkipWebhookEvent(event *ChannelEvent, payload map[string]interface{}) bool { + if event == nil { + return true + } + switch event.Type { + case EventMessageCreated, EventMessageUpdated: + // Chatwoot MessageFilterHelpers#webhook_sendable? skips activity messages. + if msg, ok := extractMessageFromPayloadData(event.Data); ok { + if msgType, ok := msg["message_type"].(string); ok && msgType == "activity" { + return true + } + } + return false + case EventContactUpdated, EventInboxUpdated: + changed, ok := payload["changed_attributes"] + if !ok { + return true + } + switch typed := changed.(type) { + case []map[string]interface{}: + return len(typed) == 0 + case []interface{}: + return len(typed) == 0 + default: + return false + } + default: + return false + } +} + +func normalizedChangedAttributes(value interface{}) []map[string]interface{} { + if value == nil { + return nil + } + switch typed := value.(type) { + case []map[string]interface{}: + return typed + case map[string]interface{}: + return normalizeChangedAttributeMap(typed) + case map[string][]interface{}: + attrs := make(map[string]interface{}, len(typed)) + for key, values := range typed { + attrs[key] = values + } + return normalizeChangedAttributeMap(attrs) + case map[string][2]interface{}: + attrs := make(map[string]interface{}, len(typed)) + for key, values := range typed { + attrs[key] = []interface{}{values[0], values[1]} + } + return normalizeChangedAttributeMap(attrs) + default: + return nil + } +} + +func extractMessageFromPayloadData(data map[string]interface{}) (map[string]interface{}, bool) { + if data == nil { + return nil, false + } + msg, ok := data["message"] + if !ok { + return nil, false + } + switch typed := msg.(type) { + case map[string]interface{}: + return typed, true + case *model.Message: + return map[string]interface{}{"id": typed.ID, "message_type": typed.MessageType}, true + case model.Message: + return map[string]interface{}{"id": typed.ID, "message_type": typed.MessageType}, true + default: + return nil, false + } +} + +func normalizeChangedAttributeMap(attrs map[string]interface{}) []map[string]interface{} { + if len(attrs) == 0 { + return nil + } + changed := make([]map[string]interface{}, 0, len(attrs)) + for key, raw := range attrs { + values, ok := raw.([]interface{}) + if !ok || len(values) < 2 { + continue + } + changed = append(changed, map[string]interface{}{ + key: map[string]interface{}{ + "previous_value": values[0], + "current_value": values[1], + }, + }) + } + return changed +} + +func signWebhookPayload(payload []byte, secret, timestamp string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(timestamp)) + mac.Write([]byte(".")) + mac.Write(payload) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) } // =========================== @@ -446,4 +750,4 @@ func (c *ChannelStatusListener) handleReauthorized(ctx context.Context, event *C // 2. Re-enable the inbox // 3. Resume webhook processing return nil -} \ No newline at end of file +} diff --git a/internal/channel/webhook_listener_test.go b/internal/channel/webhook_listener_test.go new file mode 100644 index 00000000..6508eeb2 --- /dev/null +++ b/internal/channel/webhook_listener_test.go @@ -0,0 +1,797 @@ +package channel + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func newWebhookListenerTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:webhook-listener?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("sqlite db handle: %v", err) + } + sqlDB.SetMaxOpenConns(1) + if err := db.AutoMigrate(&model.Inbox{}, &model.WebhookSubscription{}, &channelmodel.ChannelAPI{}, &model.Message{}, &model.Conversation{}, &model.InstallationConfig{}); err != nil { + t.Fatalf("migrate webhook listener db: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM webhook_subscriptions") + db.Exec("DELETE FROM channel_api") + db.Exec("DELETE FROM inboxes") + sqlDB.Close() + }) + return db +} + +func TestWebhookListenerDeliversAPIInboxWebhook(t *testing.T) { + db := newWebhookListenerTestDB(t) + secret := "api-webhook-secret" + var receivedBody []byte + var receivedType string + var receivedSignature string + var receivedTimestamp string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if _, err := uuid.Parse(r.Header.Get("X-Chatwoot-Delivery")); err != nil { + t.Fatalf("expected valid X-Chatwoot-Delivery header: %v", err) + } + receivedType = r.Header.Get("X-Chatwoot-Webhook-Type") + receivedSignature = r.Header.Get("X-Chatwoot-Signature") + receivedTimestamp = r.Header.Get("X-Chatwoot-Timestamp") + var err error + receivedBody, err = io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + inbox := &model.Inbox{AccountID: 7, Name: "API", ChannelType: "api"} + if err := db.Create(inbox).Error; err != nil { + t.Fatalf("create inbox: %v", err) + } + if err := db.Create(&channelmodel.ChannelAPI{InboxID: inbox.ID, WebhookURL: server.URL, Secret: secret}).Error; err != nil { + t.Fatalf("create api channel: %v", err) + } + + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{ + Type: EventMessageCreated, + Channel: ChannelAPI, + AccountID: inbox.AccountID, + InboxID: inbox.ID, + ConversationID: 42, + Data: map[string]interface{}{ + "message": map[string]interface{}{"id": float64(99), "content": "hello"}, + }, + Timestamp: time.Date(2026, 6, 9, 10, 0, 0, 0, time.UTC).Unix(), + } + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("deliver api inbox webhook: %v", err) + } + + if receivedType != "api_inbox_webhook" { + t.Fatalf("expected chatwoot webhook type header, got %q", receivedType) + } + if receivedTimestamp == "" { + t.Fatalf("expected X-Chatwoot-Timestamp header") + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(receivedTimestamp)) + mac.Write([]byte(".")) + mac.Write(receivedBody) + if expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)); receivedSignature != expected { + t.Fatalf("signature mismatch: got %q want %q", receivedSignature, expected) + } + + var payload map[string]interface{} + if err := json.Unmarshal(receivedBody, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if payload["event"] != "message_created" { + t.Fatalf("expected Chatwoot event name, got %#v", payload["event"]) + } + if payload["account_id"] != float64(inbox.AccountID) || payload["inbox_id"] != float64(inbox.ID) { + t.Fatalf("unexpected account/inbox ids: %#v", payload) + } +} + +func TestWebhookListenerDeliversSubscribedAccountWebhook(t *testing.T) { + db := newWebhookListenerTestDB(t) + secret := "account-webhook-secret" + var receivedBody []byte + var receivedType string + var receivedSignature string + var receivedTimestamp string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedType = r.Header.Get("X-Chatwoot-Webhook-Type") + receivedSignature = r.Header.Get("X-Chatwoot-Signature") + receivedTimestamp = r.Header.Get("X-Chatwoot-Timestamp") + var err error + receivedBody, err = io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + accountID := uint(11) + inbox := &model.Inbox{AccountID: accountID, Name: "Website", ChannelType: "web_widget"} + if err := db.Create(inbox).Error; err != nil { + t.Fatalf("create inbox: %v", err) + } + if err := db.Create(&model.WebhookSubscription{ + AccountID: accountID, + URL: server.URL, + Events: []byte(`["conversation_created"]`), + Secret: secret, + Active: true, + }).Error; err != nil { + t.Fatalf("create webhook subscription: %v", err) + } + if err := db.Create(&model.WebhookSubscription{ + AccountID: accountID, + URL: server.URL + "/unsubscribed", + Events: []byte(`["message_created"]`), + Secret: "other-secret", + Active: true, + }).Error; err != nil { + t.Fatalf("create unsubscribed webhook subscription: %v", err) + } + + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{Type: EventConversationCreated, Channel: ChannelWebWidget, AccountID: accountID, InboxID: inbox.ID, Data: map[string]interface{}{"conversation": map[string]interface{}{"id": float64(123)}}} + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("deliver account webhook: %v", err) + } + + if receivedType != "account_webhook" { + t.Fatalf("expected account_webhook header, got %q", receivedType) + } + if receivedTimestamp == "" { + t.Fatalf("expected X-Chatwoot-Timestamp header") + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(receivedTimestamp)) + mac.Write([]byte(".")) + mac.Write(receivedBody) + if expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)); receivedSignature != expected { + t.Fatalf("signature mismatch: got %q want %q", receivedSignature, expected) + } + var payload map[string]interface{} + if err := json.Unmarshal(receivedBody, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if payload["event"] != "conversation_created" { + t.Fatalf("expected conversation_created payload event, got %#v", payload["event"]) + } +} + +func TestWebhookListenerSkipsUnsubscribedAccountWebhook(t *testing.T) { + db := newWebhookListenerTestDB(t) + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + accountID := uint(12) + if err := db.Create(&model.WebhookSubscription{ + AccountID: accountID, + URL: server.URL, + Events: []byte(`["message_created"]`), + Secret: "secret", + Active: true, + }).Error; err != nil { + t.Fatalf("create webhook subscription: %v", err) + } + + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{Type: EventConversationCreated, Channel: ChannelWebWidget, AccountID: accountID, InboxID: 44, Data: map[string]interface{}{}} + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("deliver account webhook: %v", err) + } + if called { + t.Fatalf("webhook endpoint was called for an unsubscribed event") + } +} + +func TestWebhookListenerDeliversTypingWebhookToAccountAndAPIInbox(t *testing.T) { + db := newWebhookListenerTestDB(t) + accountSecret := "account-typing-secret" + apiSecret := "api-typing-secret" + var deliveries []map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + secret := accountSecret + if r.URL.Path == "/api" { + secret = apiSecret + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(r.Header.Get("X-Chatwoot-Timestamp"))) + mac.Write([]byte(".")) + mac.Write(body) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + if signature := r.Header.Get("X-Chatwoot-Signature"); signature != expected { + t.Fatalf("signature mismatch for %s: got %q want %q", r.URL.Path, signature, expected) + } + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + deliveries = append(deliveries, map[string]string{ + "path": r.URL.Path, + "type": r.Header.Get("X-Chatwoot-Webhook-Type"), + "event": payload["event"].(string), + }) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + accountID := uint(13) + inbox := &model.Inbox{AccountID: accountID, Name: "API", ChannelType: "api"} + if err := db.Create(inbox).Error; err != nil { + t.Fatalf("create inbox: %v", err) + } + if err := db.Create(&model.WebhookSubscription{AccountID: accountID, URL: server.URL + "/account", Events: []byte(`["conversation_typing_on"]`), Secret: accountSecret, Active: true}).Error; err != nil { + t.Fatalf("create webhook subscription: %v", err) + } + if err := db.Create(&channelmodel.ChannelAPI{InboxID: inbox.ID, WebhookURL: server.URL + "/api", Secret: apiSecret}).Error; err != nil { + t.Fatalf("create api channel: %v", err) + } + + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{ + Type: EventConversationTypingOn, + Channel: ChannelAPI, + AccountID: accountID, + InboxID: inbox.ID, + Data: map[string]interface{}{ + "conversation": map[string]interface{}{"id": float64(321)}, + "user": map[string]interface{}{"id": float64(654)}, + "is_private": false, + }, + } + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("deliver typing webhook: %v", err) + } + + if len(deliveries) != 2 { + t.Fatalf("expected account and api deliveries, got %#v", deliveries) + } + expected := map[string]string{"/account": "account_webhook", "/api": "api_inbox_webhook"} + for _, delivery := range deliveries { + if delivery["event"] != "conversation_typing_on" { + t.Fatalf("expected typing event, got %#v", deliveries) + } + if expected[delivery["path"]] != delivery["type"] { + t.Fatalf("unexpected delivery type: %#v", deliveries) + } + } +} + +func TestWebhookListenerDeliversContactWebhooks(t *testing.T) { + db := newWebhookListenerTestDB(t) + secret := "contact-webhook-secret" + var received []map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(r.Header.Get("X-Chatwoot-Timestamp"))) + mac.Write([]byte(".")) + mac.Write(body) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + if signature := r.Header.Get("X-Chatwoot-Signature"); signature != expected { + t.Fatalf("signature mismatch: got %q want %q", signature, expected) + } + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + received = append(received, payload) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + accountID := uint(14) + if err := db.Create(&model.WebhookSubscription{AccountID: accountID, URL: server.URL, Events: []byte(`["contact_created","contact_updated"]`), Secret: secret, Active: true}).Error; err != nil { + t.Fatalf("create webhook subscription: %v", err) + } + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + + createdEvent := &ChannelEvent{Type: EventContactCreated, AccountID: accountID, Data: map[string]interface{}{"contact": map[string]interface{}{"id": float64(5), "name": "Jane"}}} + if err := listener.OnEvent(context.Background(), createdEvent); err != nil { + t.Fatalf("deliver contact created webhook: %v", err) + } + updatedEvent := &ChannelEvent{Type: EventContactUpdated, AccountID: accountID, Data: map[string]interface{}{ + "contact": map[string]interface{}{"id": float64(5), "name": "Jane Doe"}, + "changed_attributes": map[string]interface{}{"name": []interface{}{"Jane", "Jane Doe"}}, + }} + if err := listener.OnEvent(context.Background(), updatedEvent); err != nil { + t.Fatalf("deliver contact updated webhook: %v", err) + } + + if len(received) != 2 { + t.Fatalf("expected two contact webhook deliveries, got %#v", received) + } + if received[0]["event"] != "contact_created" { + t.Fatalf("expected contact_created, got %#v", received[0]) + } + if received[1]["event"] != "contact_updated" { + t.Fatalf("expected contact_updated, got %#v", received[1]) + } + changed := received[1]["changed_attributes"].([]interface{}) + nameChange := changed[0].(map[string]interface{})["name"].(map[string]interface{}) + if nameChange["previous_value"] != "Jane" || nameChange["current_value"] != "Jane Doe" { + t.Fatalf("unexpected changed_attributes: %#v", changed) + } +} + +func TestWebhookListenerSkipsContactUpdatedWithoutChangedAttributes(t *testing.T) { + db := newWebhookListenerTestDB(t) + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + accountID := uint(15) + if err := db.Create(&model.WebhookSubscription{AccountID: accountID, URL: server.URL, Events: []byte(`["contact_updated"]`), Secret: "secret", Active: true}).Error; err != nil { + t.Fatalf("create webhook subscription: %v", err) + } + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{Type: EventContactUpdated, AccountID: accountID, Data: map[string]interface{}{"contact": map[string]interface{}{"id": float64(5)}}} + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("deliver contact updated webhook: %v", err) + } + if called { + t.Fatalf("webhook endpoint was called for contact_updated without changed attributes") + } +} + +func TestWebhookListenerDeliversInboxWebhooks(t *testing.T) { + db := newWebhookListenerTestDB(t) + secret := "inbox-webhook-secret" + var received []map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(r.Header.Get("X-Chatwoot-Timestamp"))) + mac.Write([]byte(".")) + mac.Write(body) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + if signature := r.Header.Get("X-Chatwoot-Signature"); signature != expected { + t.Fatalf("signature mismatch: got %q want %q", signature, expected) + } + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + received = append(received, payload) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + accountID := uint(16) + inbox := &model.Inbox{AccountID: accountID, Name: "Website", ChannelType: "web_widget"} + if err := db.Create(inbox).Error; err != nil { + t.Fatalf("create inbox: %v", err) + } + if err := db.Create(&model.WebhookSubscription{AccountID: accountID, URL: server.URL, Events: []byte(`["inbox_created","inbox_updated"]`), Secret: secret, Active: true}).Error; err != nil { + t.Fatalf("create webhook subscription: %v", err) + } + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + + createdEvent := &ChannelEvent{Type: EventInboxCreated, AccountID: accountID, InboxID: inbox.ID, Data: map[string]interface{}{"inbox": map[string]interface{}{"id": float64(inbox.ID), "name": "Website"}}} + if err := listener.OnEvent(context.Background(), createdEvent); err != nil { + t.Fatalf("deliver inbox created webhook: %v", err) + } + updatedEvent := &ChannelEvent{Type: EventInboxUpdated, AccountID: accountID, InboxID: inbox.ID, Data: map[string]interface{}{ + "inbox": map[string]interface{}{"id": float64(inbox.ID), "name": "Website Updated"}, + "changed_attributes": map[string]interface{}{"name": []interface{}{"Website", "Website Updated"}}, + }} + if err := listener.OnEvent(context.Background(), updatedEvent); err != nil { + t.Fatalf("deliver inbox updated webhook: %v", err) + } + + if len(received) != 2 { + t.Fatalf("expected two inbox webhook deliveries, got %#v", received) + } + if received[0]["event"] != "inbox_created" { + t.Fatalf("expected inbox_created, got %#v", received[0]) + } + if received[1]["event"] != "inbox_updated" { + t.Fatalf("expected inbox_updated, got %#v", received[1]) + } + changed := received[1]["changed_attributes"].([]interface{}) + nameChange := changed[0].(map[string]interface{})["name"].(map[string]interface{}) + if nameChange["previous_value"] != "Website" || nameChange["current_value"] != "Website Updated" { + t.Fatalf("unexpected changed_attributes: %#v", changed) + } +} + +func TestWebhookListenerDeliversConversationUpdatedWithChangedAttributes(t *testing.T) { + db := newWebhookListenerTestDB(t) + secret := "conv-updated-secret" + var received map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(r.Header.Get("X-Chatwoot-Timestamp"))) + mac.Write([]byte(".")) + mac.Write(body) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + if signature := r.Header.Get("X-Chatwoot-Signature"); signature != expected { + t.Fatalf("signature mismatch: got %q want %q", signature, expected) + } + if err := json.Unmarshal(body, &received); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + accountID := uint(22) + if err := db.Create(&model.WebhookSubscription{AccountID: accountID, URL: server.URL, Events: []byte(`["conversation_updated"]`), Secret: secret, Active: true}).Error; err != nil { + t.Fatalf("create webhook subscription: %v", err) + } + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{Type: EventConversationUpdated, AccountID: accountID, InboxID: 1, Data: map[string]interface{}{ + "conversation": map[string]interface{}{"id": float64(5)}, + "changed_attributes": map[string]interface{}{ + "custom_attributes": []interface{}{map[string]interface{}{"test": nil}, map[string]interface{}{"test": "testing custom attri webhook"}}, + }, + }} + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("deliver conversation updated webhook: %v", err) + } + if received == nil { + t.Fatalf("expected webhook delivery for conversation_updated") + } + if received["event"] != "conversation_updated" { + t.Fatalf("expected conversation_updated event, got %#v", received["event"]) + } + changed := received["changed_attributes"].([]interface{}) + customAttrs := changed[0].(map[string]interface{})["custom_attributes"].(map[string]interface{}) + prevMap, ok := customAttrs["previous_value"].(map[string]interface{}) + if !ok { + t.Fatalf("expected map previous_value, got %#v", customAttrs["previous_value"]) + } + if prevMap["test"] != nil { + t.Fatalf("expected nil test in previous_value, got %#v", prevMap["test"]) + } + currentMap, ok := customAttrs["current_value"].(map[string]interface{}) + if !ok { + t.Fatalf("expected map current_value, got %#v", customAttrs["current_value"]) + } + if currentMap["test"] != "testing custom attri webhook" { + t.Fatalf("unexpected current_value test: %#v", currentMap["test"]) + } +} + +func TestWebhookListenerDeliversConversationStatusChangedWebhook(t *testing.T) { + db := newWebhookListenerTestDB(t) + accountSecret := "account-status-secret" + apiSecret := "api-status-secret" + var deliveries []map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + secret := accountSecret + if r.URL.Path == "/api" { + secret = apiSecret + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(r.Header.Get("X-Chatwoot-Timestamp"))) + mac.Write([]byte(".")) + mac.Write(body) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + if signature := r.Header.Get("X-Chatwoot-Signature"); signature != expected { + t.Fatalf("signature mismatch: got %q want %q", signature, expected) + } + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + payload["webhook_type"] = r.Header.Get("X-Chatwoot-Webhook-Type") + payload["path"] = r.URL.Path + deliveries = append(deliveries, payload) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + accountID := uint(18) + inbox := &model.Inbox{AccountID: accountID, Name: "API", ChannelType: "api"} + if err := db.Create(inbox).Error; err != nil { + t.Fatalf("create inbox: %v", err) + } + if err := db.Create(&model.WebhookSubscription{AccountID: accountID, URL: server.URL + "/account", Events: []byte(`["conversation_status_changed"]`), Secret: accountSecret, Active: true}).Error; err != nil { + t.Fatalf("create webhook subscription: %v", err) + } + if err := db.Create(&channelmodel.ChannelAPI{InboxID: inbox.ID, WebhookURL: server.URL + "/api", Secret: apiSecret}).Error; err != nil { + t.Fatalf("create api channel: %v", err) + } + + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{Type: EventConversationResolved, Channel: ChannelAPI, AccountID: accountID, InboxID: inbox.ID, ConversationID: 77, Data: map[string]interface{}{ + "conversation": map[string]interface{}{"id": float64(77), "status": "resolved"}, + "changed_attributes": map[string]interface{}{"status": []interface{}{"open", "resolved"}}, + }} + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("deliver conversation status webhook: %v", err) + } + + if len(deliveries) != 2 { + t.Fatalf("expected account and api status deliveries, got %#v", deliveries) + } + for _, delivery := range deliveries { + if delivery["event"] != "conversation_status_changed" { + t.Fatalf("expected conversation_status_changed, got %#v", delivery) + } + changed := delivery["changed_attributes"].([]interface{}) + statusChange := changed[0].(map[string]interface{})["status"].(map[string]interface{}) + if statusChange["previous_value"] != "open" || statusChange["current_value"] != "resolved" { + t.Fatalf("unexpected status changed_attributes: %#v", changed) + } + } +} + +func TestWebhookListenerSkipsInboxUpdatedWithoutChangedAttributes(t *testing.T) { + db := newWebhookListenerTestDB(t) + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + accountID := uint(17) + if err := db.Create(&model.WebhookSubscription{AccountID: accountID, URL: server.URL, Events: []byte(`["inbox_updated"]`), Secret: "secret", Active: true}).Error; err != nil { + t.Fatalf("create webhook subscription: %v", err) + } + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{Type: EventInboxUpdated, AccountID: accountID, InboxID: 9, Data: map[string]interface{}{"inbox": map[string]interface{}{"id": float64(9)}}} + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("deliver inbox updated webhook: %v", err) + } + if called { + t.Fatalf("webhook endpoint was called for inbox_updated without changed attributes") + } +} + +func TestWebhookListenerSkipsAPIInboxWebhookWithoutURL(t *testing.T) { + db := newWebhookListenerTestDB(t) + inbox := &model.Inbox{AccountID: 8, Name: "API", ChannelType: "api"} + if err := db.Create(inbox).Error; err != nil { + t.Fatalf("create inbox: %v", err) + } + if err := db.Create(&channelmodel.ChannelAPI{InboxID: inbox.ID, Secret: "secret"}).Error; err != nil { + t.Fatalf("create api channel: %v", err) + } + + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{Type: EventMessageCreated, Channel: ChannelAPI, AccountID: inbox.AccountID, InboxID: inbox.ID, Data: map[string]interface{}{}} + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("deliver api inbox webhook: %v", err) + } + if called { + t.Fatalf("webhook endpoint was called even though api channel webhook_url is blank") + } +} +func TestWebhookListenerSkipsActivityMessageWebhook(t *testing.T) { + db := newWebhookListenerTestDB(t) + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + accountID := uint(19) + if err := db.Create(&model.WebhookSubscription{AccountID: accountID, URL: server.URL, Events: []byte(`["message_created"]`), Secret: "secret", Active: true}).Error; err != nil { + t.Fatalf("create webhook subscription: %v", err) + } + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{Type: EventMessageCreated, AccountID: accountID, InboxID: 1, Data: map[string]interface{}{ + "message": map[string]interface{}{"id": float64(10), "message_type": "activity"}, + }} + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("deliver message webhook: %v", err) + } + if called { + t.Fatalf("webhook endpoint was called for activity message") + } +} + +func TestWebhookListenerDeliversOutgoingMessageWebhook(t *testing.T) { + db := newWebhookListenerTestDB(t) + secret := "outgoing-msg-secret" + var received map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(r.Header.Get("X-Chatwoot-Timestamp"))) + mac.Write([]byte(".")) + mac.Write(body) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + if signature := r.Header.Get("X-Chatwoot-Signature"); signature != expected { + t.Fatalf("signature mismatch: got %q want %q", signature, expected) + } + if err := json.Unmarshal(body, &received); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + accountID := uint(20) + if err := db.Create(&model.WebhookSubscription{AccountID: accountID, URL: server.URL, Events: []byte(`["message_created"]`), Secret: secret, Active: true}).Error; err != nil { + t.Fatalf("create webhook subscription: %v", err) + } + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{Type: EventMessageCreated, AccountID: accountID, InboxID: 1, Data: map[string]interface{}{ + "message": map[string]interface{}{"id": float64(20), "message_type": "outgoing", "content": "Hello"}, + }} + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("deliver outgoing message webhook: %v", err) + } + if received == nil { + t.Fatalf("expected webhook delivery for outgoing message") + } + if received["event"] != "message_created" { + t.Fatalf("expected message_created event, got %#v", received["event"]) + } +} +func TestWebhookListenerDeliversWebwidgetTriggered(t *testing.T) { + db := newWebhookListenerTestDB(t) + secret := "webwidget-secret" + var received map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(r.Header.Get("X-Chatwoot-Timestamp"))) + mac.Write([]byte(".")) + mac.Write(body) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + if signature := r.Header.Get("X-Chatwoot-Signature"); signature != expected { + t.Fatalf("signature mismatch: got %q want %q", signature, expected) + } + if err := json.Unmarshal(body, &received); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + accountID := uint(23) + inbox := &model.Inbox{AccountID: accountID, Name: "Website", ChannelType: "web_widget"} + if err := db.Create(inbox).Error; err != nil { + t.Fatalf("create inbox: %v", err) + } + if err := db.Create(&model.WebhookSubscription{AccountID: accountID, URL: server.URL, Events: []byte(`["webwidget_triggered"]`), Secret: secret, Active: true}).Error; err != nil { + t.Fatalf("create webhook subscription: %v", err) + } + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{Type: EventWebwidgetTriggered, AccountID: accountID, InboxID: inbox.ID, Data: map[string]interface{}{ + "contact_inbox": map[string]interface{}{"id": float64(1)}, + "event_info": map[string]interface{}{"country": "US"}, + }} + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("deliver webwidget triggered webhook: %v", err) + } + if received == nil { + t.Fatalf("expected webhook delivery for webwidget_triggered") + } + if received["event"] != "webwidget_triggered" { + t.Fatalf("expected webwidget_triggered event, got %#v", received["event"]) + } +} +func TestWebhookListenerMarksMessageFailedOnAPIInboxWebhookError(t *testing.T) { + db := newWebhookListenerTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + accountID := uint(21) + inbox := &model.Inbox{AccountID: accountID, Name: "API", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + require.NoError(t, db.Create(&channelmodel.ChannelAPI{InboxID: inbox.ID, WebhookURL: server.URL, Secret: "secret"}).Error) + + msg := &model.Message{AccountID: accountID, InboxID: inbox.ID, ConversationID: 1, Content: "hello", MessageType: "outgoing", Status: "sent"} + require.NoError(t, db.Create(msg).Error) + + listener := NewWebhookListenerWithClient(server.Client()).WithDB(db) + event := &ChannelEvent{Type: EventMessageCreated, Channel: ChannelAPI, AccountID: accountID, InboxID: inbox.ID, Data: map[string]interface{}{ + "message": map[string]interface{}{"id": float64(msg.ID), "message_type": "outgoing"}, + }} + // deliverWebhook should not return error (errors are logged, not propagated) + err := listener.OnEvent(context.Background(), event) + require.NoError(t, err) + + var updated model.Message + require.NoError(t, db.First(&updated, msg.ID).Error) + assert.Equal(t, "failed", updated.Status) +} + +func TestWebhookListenerUsesChatwootDefaultTimeout(t *testing.T) { + listener := NewWebhookListener() + + require.NotNil(t, listener.httpClient) + assert.Equal(t, 5*time.Second, listener.httpClient.Timeout) +} + +func TestWebhookListenerUsesConfiguredWebhookTimeout(t *testing.T) { + db := newWebhookListenerTestDB(t) + require.NoError(t, db.Create(&model.InstallationConfig{Name: "WEBHOOK_TIMEOUT", Value: "9"}).Error) + + listener := NewWebhookListenerWithDB(db) + + require.NotNil(t, listener.httpClient) + assert.Equal(t, 9*time.Second, listener.httpClient.Timeout) +} + +func TestWebhookListenerFallsBackForInvalidConfiguredWebhookTimeout(t *testing.T) { + db := newWebhookListenerTestDB(t) + require.NoError(t, db.Create(&model.InstallationConfig{Name: "WEBHOOK_TIMEOUT", Value: "-1"}).Error) + + listener := NewWebhookListenerWithDB(db) + + require.NotNil(t, listener.httpClient) + assert.Equal(t, 5*time.Second, listener.httpClient.Timeout) +} diff --git a/internal/handler/api/v1/csat_survey_handler.go b/internal/handler/api/v1/csat_survey_handler.go index 03b1e388..b09dc688 100644 --- a/internal/handler/api/v1/csat_survey_handler.go +++ b/internal/handler/api/v1/csat_survey_handler.go @@ -123,18 +123,22 @@ func (h *CsatSurveyHandler) UpdateReviewNotes(c *gin.Context) { c.JSON(http.StatusOK, h.serializeCsatSurveyResponse(c.Request.Context(), resp)) } -// Update updates a CSAT survey response (rating, feedback, review_notes). +// Update updates CSAT review notes for an account-scoped survey response. // PATCH /api/v1/accounts/:account_id/csat_survey_responses/:id func (h *CsatSurveyHandler) Update(c *gin.Context) { + accountID, err := parseUintParam(c, "account_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } id, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } + userID := getUserID(c) var body struct { - Rating int `json:"rating"` - FeedbackMessage string `json:"feedback_message"` CsatReviewNotes string `json:"csat_review_notes"` ReviewNotes string `json:"review_notes"` } @@ -146,17 +150,32 @@ func (h *CsatSurveyHandler) Update(c *gin.Context) { response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update csat survey response") return } + if _, err := h.svc.GetByIDForAccount(c.Request.Context(), accountID, id); err != nil { + handleServiceError(c, err) + return + } notes := body.CsatReviewNotes if notes == "" { notes = body.ReviewNotes } - resp, svcErr := h.svc.Update(c.Request.Context(), id, body.Rating, body.FeedbackMessage, notes) - if svcErr != nil { - handleServiceError(c, svcErr) + if err := h.svc.UpdateReviewNotes(c.Request.Context(), id, notes, userID); err != nil { + handleServiceError(c, err) + return + } + resp, err := h.svc.GetByIDForAccount(c.Request.Context(), accountID, id) + if err != nil { + handleServiceError(c, err) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "CsatSurveyResponse", + AuditableID: resp.ID, + Action: "update", + AuditedChanges: gin.H{"csat_review_notes": notes}, + }) c.JSON(http.StatusOK, h.serializeCsatSurveyResponse(c.Request.Context(), resp)) } @@ -265,19 +284,13 @@ func buildCsatFilter(c *gin.Context) automation.CsatListFilter { } } - // since filter (Chatwoot frontend sends Unix seconds; keep RFC3339 compatibility for local callers) - if v := c.Query("since"); v != "" { - t, err := parseCsatQueryTime(v) - if err == nil { - filter.Since = &t - } - } - - // until filter (Chatwoot frontend sends Unix seconds; keep RFC3339 compatibility for local callers) - if v := c.Query("until"); v != "" { - t, err := parseCsatQueryTime(v) - if err == nil { - filter.Until = &t + // Chatwoot DateRangeHelper applies a range only when both since and until are present. + if sinceRaw, untilRaw := c.Query("since"), c.Query("until"); sinceRaw != "" && untilRaw != "" { + since, sinceErr := parseCsatQueryTime(sinceRaw) + until, untilErr := parseCsatQueryTime(untilRaw) + if sinceErr == nil && untilErr == nil { + filter.Since = &since + filter.Until = &until } } @@ -287,12 +300,6 @@ func buildCsatFilter(c *gin.Context) automation.CsatListFilter { filter.Page = int(n) } } - if v := c.Query("per_page"); v != "" { - if n, err := csatParseUintFull(v); err == nil && n > 0 { - filter.PageSize = int(n) - } - } - return filter } @@ -479,7 +486,7 @@ func (h *CsatSurveyHandler) Download(c *gin.Context) { contactEmail, contactPhone, conversationLink, - r.CreatedAt.Format(time.RFC3339), + formatCsatCSVTimestamp(r.CreatedAt), r.CsatReviewNotes, } if err := writer.Write(record); err != nil { @@ -502,6 +509,10 @@ func (h *CsatSurveyHandler) Download(c *gin.Context) { } } +func formatCsatCSVTimestamp(value time.Time) string { + return value.Format("2006-01-02 15:04:05 MST") +} + func csatConversationURL(req *http.Request, accountID uint, conversation *model.Conversation) string { if conversation == nil { return "" diff --git a/internal/handler/api/v1/csat_survey_handler_test.go b/internal/handler/api/v1/csat_survey_handler_test.go index d6fd2b39..9cb1ff4a 100644 --- a/internal/handler/api/v1/csat_survey_handler_test.go +++ b/internal/handler/api/v1/csat_survey_handler_test.go @@ -148,6 +148,62 @@ func (s *CsatSurveyHandlerTestSuite) TestList_ChatwootPayloadAndFilters() { assert.Equal(s.T(), reviewer.Name, reviewerPayload["name"]) } +func (s *CsatSurveyHandlerTestSuite) TestList_IgnoresPerPageAndUsesChatwootFixedPageSize() { + createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second) + _, _, contact, conversation, _ := s.seedAccountCsatResponseGraph(createdAt, 5) + for i := 0; i < 29; i++ { + response := &automation.CsatSurveyResponse{ + AccountID: s.account.ID, + ConversationID: conversation.ID, + ContactID: contact.ID, + Rating: 4, + } + s.Require().NoError(s.db.Create(response).Error) + recordedAt := createdAt.Add(time.Duration(i+1) * time.Minute) + s.Require().NoError(s.db.Model(response).Updates(map[string]any{"created_at": recordedAt, "updated_at": recordedAt}).Error) + } + + r := gin.New() + r.GET("/api/v1/accounts/:account_id/csat_survey_responses", s.handler.List) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses?page=1&per_page=5", s.account.ID), nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var payload []map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + s.Require().Len(payload, 25) +} + +func (s *CsatSurveyHandlerTestSuite) TestList_UsesChatwootDateRangeBoundary() { + baseTime := time.Now().Add(-2 * time.Hour).Truncate(time.Second) + _, _, contact, conversation, _ := s.seedAccountCsatResponseGraph(baseTime.Add(30*time.Minute), 5) + onUntil := &automation.CsatSurveyResponse{AccountID: s.account.ID, ConversationID: conversation.ID, ContactID: contact.ID, Rating: 4} + s.Require().NoError(s.db.Create(onUntil).Error) + s.Require().NoError(s.db.Model(onUntil).Updates(map[string]any{"created_at": baseTime.Add(time.Hour), "updated_at": baseTime.Add(time.Hour)}).Error) + + r := gin.New() + r.GET("/api/v1/accounts/:account_id/csat_survey_responses", s.handler.List) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses?since=%d", s.account.ID, baseTime.Add(30*time.Minute).Unix()), nil) + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + var payload []map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + s.Require().Len(payload, 2) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses?since=%d&until=%d", s.account.ID, baseTime.Unix(), baseTime.Add(time.Hour).Unix()), nil) + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + payload = nil + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + s.Require().Len(payload, 1) + assert.Equal(s.T(), float64(5), payload[0]["rating"]) +} + func (s *CsatSurveyHandlerTestSuite) TestMetrics_ChatwootPayloadAndFilters() { createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second) agent, _, _, conversation, _ := s.seedAccountCsatResponseGraph(createdAt, 5) @@ -206,11 +262,16 @@ func (s *CsatSurveyHandlerTestSuite) TestDownload_ChatwootCSVAndFilters() { assert.Equal(s.T(), contact.Email, rows[1][4]) assert.Equal(s.T(), contact.PhoneNumber, rows[1][5]) assert.Equal(s.T(), fmt.Sprintf("https://app.example.test/app/accounts/%d/conversations/42", s.account.ID), rows[1][6]) - assert.Equal(s.T(), createdAt.Format(time.RFC3339), rows[1][7]) + assert.Equal(s.T(), "2026-06-05 10:30:00 UTC", rows[1][7]) assert.Equal(s.T(), "Needs follow up", rows[1][8]) assert.Equal(s.T(), "Reporting period 1970-01-01 to 3000-01-01", rows[2][0]) } +func (s *CsatSurveyHandlerTestSuite) TestDownload_FormatsRecordedAtLikeChatwootCSV() { + recordedAt := time.Date(2026, 6, 5, 10, 30, 0, 0, time.UTC) + assert.Equal(s.T(), "2026-06-05 10:30:00 UTC", formatCsatCSVTimestamp(recordedAt)) +} + func (s *CsatSurveyHandlerTestSuite) TestUpdateReviewNotes_Success() { _, reviewer, _, _, _ := s.seedAccountCsatResponseGraph(time.Now().Add(-time.Hour), 5) var survey automation.CsatSurveyResponse @@ -237,22 +298,49 @@ func (s *CsatSurveyHandlerTestSuite) TestUpdateReviewNotes_Success() { } func (s *CsatSurveyHandlerTestSuite) TestUpdate_Success() { - survey := &automation.CsatSurveyResponse{AccountID: s.account.ID, ConversationID: 1, Rating: 5} + _, reviewer, _, _, _ := s.seedAccountCsatResponseGraph(time.Now().Add(-time.Hour), 5) + var survey automation.CsatSurveyResponse + s.Require().NoError(s.db.First(&survey).Error) + originalRating := survey.Rating + + r := gin.New() + r.PATCH("/api/v1/accounts/:account_id/csat_survey_responses/:id", func(c *gin.Context) { + c.Set("user_id", float64(reviewer.ID)) + s.handler.Update(c) + }) + + w := httptest.NewRecorder() + body := `{"csat_review_notes":"updated notes","rating":1,"feedback_message":"ignored"}` + req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses/%d", s.account.ID, survey.ID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var payload map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(s.T(), "updated notes", payload["csat_review_notes"]) + assert.Equal(s.T(), float64(originalRating), payload["rating"]) + reviewerPayload := payload["review_notes_updated_by"].(map[string]any) + assert.Equal(s.T(), reviewer.Name, reviewerPayload["name"]) +} + +func (s *CsatSurveyHandlerTestSuite) TestUpdate_NotFoundAcrossAccountScope() { + survey := &automation.CsatSurveyResponse{AccountID: s.account.ID + 1, ConversationID: 1, ContactID: 1, Rating: 5} s.Require().NoError(s.db.Create(survey).Error) r := gin.New() - r.PUT("/api/v1/accounts/:account_id/csats/:id", func(c *gin.Context) { + r.PATCH("/api/v1/accounts/:account_id/csat_survey_responses/:id", func(c *gin.Context) { c.Set("user_id", float64(1)) s.handler.Update(c) }) w := httptest.NewRecorder() - body := `{"review_notes":"updated notes"}` - req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/csats/%d", s.account.ID, survey.ID), bytes.NewBufferString(body)) + body := `{"csat_review_notes":"updated notes"}` + req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses/%d", s.account.ID, survey.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) - assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *CsatSurveyHandlerTestSuite) TestPublicCsatShowAndUpdate_Success() { diff --git a/internal/handler/api/v1/inbox_handler_parity_test.go b/internal/handler/api/v1/inbox_handler_parity_test.go index cd89288a..552f90f3 100644 --- a/internal/handler/api/v1/inbox_handler_parity_test.go +++ b/internal/handler/api/v1/inbox_handler_parity_test.go @@ -16,6 +16,7 @@ import ( "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" ) @@ -33,7 +34,7 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) { _ = sqlDB.Close() } }) - require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{})) account := &model.Account{Name: "Inbox Parity", Locale: "en", Active: true} require.NoError(t, db.Create(account).Error) @@ -123,7 +124,7 @@ func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) { _ = sqlDB.Close() } }) - require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{})) account := &model.Account{Name: "Inbox Binding", Locale: "en", Active: true} require.NoError(t, db.Create(account).Error) @@ -256,7 +257,7 @@ func TestInboxHandler_ChatwootCreateRejectsAccountInboxLimit(t *testing.T) { _ = sqlDB.Close() } }) - require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{})) account := &model.Account{Name: "Inbox Limit", Locale: "en", Active: true, InboxLimit: 1} require.NoError(t, db.Create(account).Error) @@ -290,7 +291,7 @@ func TestInboxHandler_ChatwootChannelSpecificConfigDepth(t *testing.T) { _ = sqlDB.Close() } }) - require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{})) account := &model.Account{Name: "Inbox Channel Depth", Locale: "en", Active: true} require.NoError(t, db.Create(account).Error) diff --git a/internal/handler/widget/widget_handler.go b/internal/handler/widget/widget_handler.go index 94c64d5a..2b04021b 100644 --- a/internal/handler/widget/widget_handler.go +++ b/internal/handler/widget/widget_handler.go @@ -1,6 +1,8 @@ package widget import ( + "context" + "errors" "net/http" "strconv" "strings" @@ -89,6 +91,14 @@ func (h *WidgetHandler) Config(c *gin.Context) { resp, err := h.widgetService.Init(c.Request.Context(), req) if err != nil { + if widgetWebsiteTokenNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } + if strings.Contains(err.Error(), "Account is suspended") { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Account is suspended"}) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -140,6 +150,10 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { req.WidgetToken = widgetToken resp, err := h.widgetService.SendMessage(c.Request.Context(), req) if err != nil { + if errors.Is(err, service.ErrWidgetMessageContentTooLong) && c.FullPath() != "/widget/messages" { + c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error()}) + return + } status := http.StatusBadRequest if err.Error() == "invalid widget_token" || err.Error() == "widget_token required" { status = http.StatusUnauthorized @@ -205,13 +219,10 @@ func (h *WidgetHandler) GetLatestMessages(c *gin.Context) { return } - offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "25")) - if limit <= 0 || limit > 100 { - limit = 25 - } + after, _ := strconv.ParseUint(c.DefaultQuery("after", "0"), 10, 64) + before, _ := strconv.ParseUint(c.DefaultQuery("before", "0"), 10, 64) - messages, total, conversation, err := h.widgetService.GetLatestConversationMessages(c.Request.Context(), widgetToken, offset, limit) + messages, total, conversation, err := h.widgetService.GetLatestConversationMessages(c.Request.Context(), widgetToken, uint(after), uint(before)) if err != nil { status := http.StatusBadRequest if err.Error() == "invalid widget_token" { @@ -229,7 +240,7 @@ func (h *WidgetHandler) GetLatestMessages(c *gin.Context) { } payload = append(payload, messagePayload) } - meta := gin.H{"total": total, "offset": offset, "limit": limit} + meta := gin.H{"total": total} if conversation != nil && conversation.ContactLastSeenAt != nil { meta["contact_last_seen_at"] = *conversation.ContactLastSeenAt } @@ -378,6 +389,10 @@ func (h *WidgetHandler) GetCableToken(c *gin.Context) { func (h *WidgetHandler) UpdateContact(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { + if isChatwootWidgetRoute(c) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } @@ -406,6 +421,10 @@ func (h *WidgetHandler) UpdateContact(c *gin.Context) { AdditionalAttributes: req.AdditionalAttributes, }) if err != nil { + if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } status := http.StatusBadRequest if err.Error() == "invalid widget_token" { status = http.StatusUnauthorized @@ -421,12 +440,20 @@ func (h *WidgetHandler) UpdateContact(c *gin.Context) { func (h *WidgetHandler) GetContact(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { + if isChatwootWidgetRoute(c) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } contact, err := h.widgetService.GetContact(c.Request.Context(), widgetToken) if err != nil { + if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } status := http.StatusBadRequest if err.Error() == "invalid widget_token" { status = http.StatusUnauthorized @@ -440,6 +467,10 @@ func (h *WidgetHandler) GetContact(c *gin.Context) { func (h *WidgetHandler) DestroyContactCustomAttributes(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { + if isChatwootWidgetRoute(c) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } @@ -453,6 +484,10 @@ func (h *WidgetHandler) DestroyContactCustomAttributes(c *gin.Context) { } contact, err := h.widgetService.DeleteContactCustomAttributes(c.Request.Context(), widgetToken, req.CustomAttributes) if err != nil { + if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -558,21 +593,29 @@ func (h *WidgetHandler) ToggleTyping(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"status": "ok"}) + c.Status(http.StatusOK) } func (h *WidgetHandler) UpdateLastSeen(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { + if isChatwootWidgetRoute(c) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } - conversation, err := h.widgetService.UpdateLastSeen(c.Request.Context(), widgetToken) + _, err := h.widgetService.UpdateLastSeen(c.Request.Context(), widgetToken) if err != nil { + if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"contact_last_seen_at": conversation.ContactLastSeenAt}) + c.Status(http.StatusOK) } func (h *WidgetHandler) ToggleStatus(c *gin.Context) { @@ -581,17 +624,29 @@ func (h *WidgetHandler) ToggleStatus(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } - conversation, err := h.widgetService.ResolveLatestConversation(c.Request.Context(), widgetToken) + _, err := h.widgetService.ResolveLatestConversation(c.Request.Context(), widgetToken) if err != nil { + if errors.Is(err, service.ErrWidgetEndConversationDisabled) { + c.Status(http.StatusForbidden) + return + } + if errors.Is(err, service.ErrWidgetConversationNotFound) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, widgetConversationPayload(*conversation)) + c.Status(http.StatusOK) } func (h *WidgetHandler) SetConversationCustomAttributes(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { + if isChatwootWidgetRoute(c) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } @@ -602,17 +657,25 @@ func (h *WidgetHandler) SetConversationCustomAttributes(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } - conversation, err := h.widgetService.SetLatestConversationCustomAttributes(c.Request.Context(), widgetToken, req.CustomAttributes) + _, err := h.widgetService.SetLatestConversationCustomAttributes(c.Request.Context(), widgetToken, req.CustomAttributes) if err != nil { + if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, conversation) + c.Status(http.StatusOK) } func (h *WidgetHandler) DestroyConversationCustomAttributes(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { + if isChatwootWidgetRoute(c) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } @@ -625,6 +688,10 @@ func (h *WidgetHandler) DestroyConversationCustomAttributes(c *gin.Context) { } conversation, err := h.widgetService.DeleteLatestConversationCustomAttributes(c.Request.Context(), widgetToken, req.CustomAttribute) if err != nil { + if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -634,11 +701,19 @@ func (h *WidgetHandler) DestroyConversationCustomAttributes(c *gin.Context) { func (h *WidgetHandler) ListInboxMembers(c *gin.Context) { websiteToken := c.Query("website_token") if websiteToken == "" { + if isChatwootWidgetRoute(c) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"}) return } members, err := h.widgetService.GetInboxMembersByWebsiteToken(c.Request.Context(), websiteToken) if err != nil { + if isChatwootWidgetRoute(c) && widgetWebsiteTokenNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -648,11 +723,19 @@ func (h *WidgetHandler) ListInboxMembers(c *gin.Context) { func (h *WidgetHandler) ListCampaigns(c *gin.Context) { websiteToken := c.Query("website_token") if websiteToken == "" { + if isChatwootWidgetRoute(c) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"}) return } campaigns, err := h.widgetService.GetCampaignsByWebsiteToken(c.Request.Context(), websiteToken) if err != nil { + if isChatwootWidgetRoute(c) && widgetWebsiteTokenNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -660,16 +743,28 @@ func (h *WidgetHandler) ListCampaigns(c *gin.Context) { } func (h *WidgetHandler) CreateEvent(c *gin.Context) { - websiteToken := c.Query("website_token") var req struct { - Name string `json:"name"` - EventInfo map[string]any `json:"event_info"` + Name string `json:"name" form:"name"` + WebsiteToken string `json:"website_token" form:"website_token"` + EventInfo map[string]any `json:"event_info" form:"event_info"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } + websiteToken := strings.TrimSpace(c.Query("website_token")) + if websiteToken == "" { + websiteToken = strings.TrimSpace(req.WebsiteToken) + } if err := h.widgetService.TrackEvent(c.Request.Context(), websiteToken, widgetTokenFromRequest(c), req.Name, req.EventInfo); err != nil { + if isChatwootWidgetRoute(c) && widgetWebsiteTokenNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } + if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -679,6 +774,10 @@ func (h *WidgetHandler) CreateEvent(c *gin.Context) { func (h *WidgetHandler) AddLabel(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { + if isChatwootWidgetRoute(c) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } @@ -690,6 +789,10 @@ func (h *WidgetHandler) AddLabel(c *gin.Context) { return } if err := h.widgetService.AddLabelToLatestConversation(c.Request.Context(), widgetToken, req.Label); err != nil { + if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -699,10 +802,18 @@ func (h *WidgetHandler) AddLabel(c *gin.Context) { func (h *WidgetHandler) RemoveLabel(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) if widgetToken == "" { + if isChatwootWidgetRoute(c) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) return } if err := h.widgetService.RemoveLabelFromLatestConversation(c.Request.Context(), widgetToken, c.Param("label_id")); err != nil { + if isChatwootWidgetRoute(c) && widgetConversationNotFoundLike(err) { + c.Status(http.StatusNotFound) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -716,11 +827,19 @@ func (h *WidgetHandler) SendTranscript(c *gin.Context) { return } if err := h.widgetService.SendTranscript(c.Request.Context(), widgetToken); err != nil { - status := widgetErrorStatus(err) - if strings.Contains(err.Error(), "conversation not found") { - status = http.StatusTooManyRequests + if errors.Is(err, service.ErrWidgetConversationNotFound) { + c.Status(http.StatusTooManyRequests) + return } - c.JSON(status, gin.H{"error": err.Error()}) + if errors.Is(err, service.ErrEmailTranscriptDisabled) { + c.Status(http.StatusPaymentRequired) + return + } + if errors.Is(err, service.ErrEmailRateLimited) { + c.Status(http.StatusTooManyRequests) + return + } + c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } c.Status(http.StatusOK) @@ -734,10 +853,10 @@ func (h *WidgetHandler) AddDyteParticipantToMeeting(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } - resp, err := h.widgetService.AddDyteParticipant(c.Request.Context(), c.Query("website_token"), req.MessageID) + resp, err := h.widgetService.AddDyteParticipant(c.Request.Context(), c.Query("website_token"), widgetTokenFromRequest(c), req.MessageID) if err != nil { status := http.StatusUnprocessableEntity - if strings.Contains(err.Error(), "website_token") { + if strings.Contains(err.Error(), "website_token") || strings.Contains(err.Error(), "widget_token") { status = http.StatusBadRequest } c.JSON(status, gin.H{"error": err.Error()}) @@ -803,7 +922,12 @@ func (h *WidgetHandler) PublicListConversations(c *gin.Context) { } payload := make([]gin.H, 0, len(conversations)) for _, conversation := range conversations { - payload = append(payload, publicConversationPayload(conversation, nil)) + messages, _, _, err := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), publicDisplayID(conversation), service.PublicMessageListOptions{}) + if err != nil { + c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) + return + } + payload = append(payload, h.publicConversationPayload(c.Request.Context(), conversation, messages)) } c.JSON(http.StatusOK, payload) } @@ -821,7 +945,7 @@ func (h *WidgetHandler) PublicCreateConversation(c *gin.Context) { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, publicConversationPayload(*conversation, nil)) + c.JSON(http.StatusOK, h.publicConversationPayload(c.Request.Context(), *conversation, nil)) } func (h *WidgetHandler) PublicGetConversation(c *gin.Context) { @@ -834,8 +958,8 @@ func (h *WidgetHandler) PublicGetConversation(c *gin.Context) { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } - messages, _, _, _ := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, 0, 100) - c.JSON(http.StatusOK, publicConversationPayload(*conversation, messages)) + messages, _, _, _ := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, service.PublicMessageListOptions{}) + c.JSON(http.StatusOK, h.publicConversationPayload(c.Request.Context(), *conversation, messages)) } func (h *WidgetHandler) PublicToggleStatus(c *gin.Context) { @@ -848,7 +972,7 @@ func (h *WidgetHandler) PublicToggleStatus(c *gin.Context) { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, publicConversationPayload(*conversation, nil)) + c.JSON(http.StatusOK, h.publicConversationPayload(c.Request.Context(), *conversation, nil)) } func (h *WidgetHandler) PublicToggleTyping(c *gin.Context) { @@ -889,15 +1013,20 @@ func (h *WidgetHandler) PublicListMessages(c *gin.Context) { return } offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "25")) - messages, _, conversation, err := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, offset, limit) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "0")) + before, _ := strconv.ParseUint(c.Query("before"), 10, 64) + messages, _, conversation, err := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, service.PublicMessageListOptions{Before: uint(before), Offset: offset, Limit: limit}) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } payload := make([]gin.H, 0, len(messages)) for _, message := range messages { - payload = append(payload, publicMessagePayload(message, *conversation)) + messagePayload := publicMessagePayload(message, *conversation) + if attachments, err := h.widgetService.GetMessageAttachments(c.Request.Context(), message.ID); err == nil && len(attachments) > 0 { + messagePayload["attachments"] = widgetAttachmentPayloads(attachments) + } + payload = append(payload, messagePayload) } c.JSON(http.StatusOK, payload) } @@ -912,12 +1041,16 @@ func (h *WidgetHandler) PublicCreateMessage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } - message, conversation, err := h.widgetService.PublicCreateMessage(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, req) + message, conversation, attachments, err := h.widgetService.PublicCreateMessage(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, req) if err != nil { c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, publicMessagePayload(*message, *conversation)) + payload := publicMessagePayload(*message, *conversation) + if len(attachments) > 0 { + payload["attachments"] = widgetAttachmentPayloads(attachments) + } + c.JSON(http.StatusOK, payload) } func (h *WidgetHandler) PublicUpdateMessage(c *gin.Context) { @@ -1013,26 +1146,51 @@ func bindWidgetSendMessageRequest(c *gin.Context) (service.WidgetSendMessageRequ conversationID = &value } } + var replyTo *uint + if rawID := firstFormValue(form.Value, "reply_to", "message[reply_to]"); rawID != "" { + if id, err := strconv.ParseUint(rawID, 10, 64); err == nil && id > 0 { + value := uint(id) + replyTo = &value + } + } attachments := form.Value["message[attachments][]"] if len(attachments) == 0 { attachments = form.Value["attachments[]"] } + customAttributes := map[string]any{} + for key, values := range form.Value { + if strings.HasPrefix(key, "custom_attributes[") && strings.HasSuffix(key, "]") && len(values) > 0 { + attrKey := strings.TrimSuffix(strings.TrimPrefix(key, "custom_attributes["), "]") + if attrKey != "" { + customAttributes[attrKey] = values[0] + } + } + } + if len(customAttributes) == 0 { + customAttributes = nil + } return service.WidgetSendMessageRequest{ - Content: content, - ContentType: contentType, - ConversationID: conversationID, - AttachmentIDs: attachments, + Content: content, + ContentType: contentType, + ConversationID: conversationID, + AttachmentIDs: attachments, + CustomAttributes: customAttributes, + Labels: form.Value["labels[]"], + ReplyTo: replyTo, }, nil } var body struct { - Content string `json:"content"` - ContentType string `json:"content_type"` - ConversationID *uint `json:"conversation_id"` - Attachments []string `json:"attachments"` - Message struct { + Content string `json:"content"` + ContentType string `json:"content_type"` + ConversationID *uint `json:"conversation_id"` + Attachments []string `json:"attachments"` + CustomAttributes map[string]any `json:"custom_attributes"` + Labels []string `json:"labels"` + Message struct { Content string `json:"content"` Attachments []string `json:"attachments"` + ReplyTo *uint `json:"reply_to"` } `json:"message"` } if err := c.ShouldBindJSON(&body); err != nil { @@ -1047,10 +1205,13 @@ func bindWidgetSendMessageRequest(c *gin.Context) (service.WidgetSendMessageRequ attachments = body.Message.Attachments } return service.WidgetSendMessageRequest{ - Content: content, - ContentType: body.ContentType, - ConversationID: body.ConversationID, - AttachmentIDs: attachments, + Content: content, + ContentType: body.ContentType, + ConversationID: body.ConversationID, + AttachmentIDs: attachments, + CustomAttributes: body.CustomAttributes, + Labels: body.Labels, + ReplyTo: body.Message.ReplyTo, }, nil } @@ -1175,10 +1336,26 @@ func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, err } func bindPublicMessageRequest(c *gin.Context) (service.PublicMessageRequest, error) { + if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") { + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { + return service.PublicMessageRequest{}, err + } + form := c.Request.MultipartForm + attachments := form.Value["attachments[]"] + if len(attachments) == 0 { + attachments = form.Value["message[attachments][]"] + } + return service.PublicMessageRequest{ + Content: firstFormValue(form.Value, "content", "message[content]"), + EchoID: firstFormValue(form.Value, "echo_id", "message[echo_id]"), + AttachmentIDs: attachments, + }, nil + } var body struct { Content string `json:"content"` EchoID string `json:"echo_id"` SubmittedValues []map[string]any `json:"submitted_values"` + Attachments []string `json:"attachments"` } if err := c.ShouldBindJSON(&body); err != nil { return service.PublicMessageRequest{}, err @@ -1187,6 +1364,7 @@ func bindPublicMessageRequest(c *gin.Context) (service.PublicMessageRequest, err Content: body.Content, EchoID: body.EchoID, SubmittedValues: body.SubmittedValues, + AttachmentIDs: body.Attachments, }, nil } @@ -1213,7 +1391,7 @@ func publicContactPayload(contactInbox *model.ContactInbox, contact *model.Conta } } -func publicConversationPayload(conversation model.Conversation, messages []model.Message) gin.H { +func (h *WidgetHandler) publicConversationPayload(ctx context.Context, conversation model.Conversation, messages []model.Message) gin.H { payload := gin.H{ "id": publicDisplayID(conversation), "uuid": conversation.UUID, @@ -1225,7 +1403,11 @@ func publicConversationPayload(conversation model.Conversation, messages []model } messagePayloads := make([]gin.H, 0, len(messages)) for _, message := range messages { - messagePayloads = append(messagePayloads, publicMessagePayload(message, conversation)) + messagePayload := publicMessagePayload(message, conversation) + if attachments, err := h.widgetService.GetMessageAttachments(ctx, message.ID); err == nil && len(attachments) > 0 { + messagePayload["attachments"] = widgetAttachmentPayloads(attachments) + } + messagePayloads = append(messagePayloads, messagePayload) } payload["messages"] = messagePayloads return payload @@ -1270,7 +1452,27 @@ func publicUnix(value *int64) int64 { return *value } +func isChatwootWidgetRoute(c *gin.Context) bool { + return strings.HasPrefix(c.FullPath(), "/api/v1/widget/") +} + +func widgetConversationNotFoundLike(err error) bool { + if errors.Is(err, service.ErrWidgetConversationNotFound) { + return true + } + msg := err.Error() + return strings.Contains(msg, "invalid widget_token") || strings.Contains(msg, "conversation not found") || strings.Contains(msg, "record not found") +} + +func widgetWebsiteTokenNotFoundLike(err error) bool { + msg := err.Error() + return strings.Contains(msg, "website_token is required") || strings.Contains(msg, "no inbox found for website_token") || strings.Contains(msg, "no web_widget inboxes found") +} + func widgetErrorStatus(err error) int { + if errors.Is(err, service.ErrWidgetMessageContentTooLong) { + return http.StatusUnprocessableEntity + } msg := err.Error() if strings.Contains(msg, "invalid widget_token") || strings.Contains(msg, "HMAC failed") { return http.StatusUnauthorized diff --git a/internal/handler/widget/widget_handler_test.go b/internal/handler/widget/widget_handler_test.go index 6bd1cdeb..89bf5f5f 100644 --- a/internal/handler/widget/widget_handler_test.go +++ b/internal/handler/widget/widget_handler_test.go @@ -7,10 +7,12 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "mime/multipart" "net/http" "net/http/httptest" "strconv" + "strings" "testing" "time" @@ -41,8 +43,23 @@ func (n *noopTypingIndicatorWidget) SetTypingOff(_ context.Context, _ uint, _ ui return nil } +type recordingWidgetTranscriptDeliverer struct { + requests []automation.AutomationTranscriptRequest + err error +} + +func (d *recordingWidgetTranscriptDeliverer) DeliverTranscript(_ context.Context, req automation.AutomationTranscriptRequest) (automation.ActionDeliveryResult, error) { + d.requests = append(d.requests, req) + return automation.ActionDeliveryResult{DeliveryType: "email_transcript", Target: req.Recipient}, d.err +} + // setupWidgetHandlerTest creates in-memory SQLite DB, repos, services, handler, and router. func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler) { + db, router, handler, _ := setupWidgetHandlerTestWithTranscriptDeliverer(t) + return db, router, handler +} + +func setupWidgetHandlerTestWithTranscriptDeliverer(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler, *recordingWidgetTranscriptDeliverer) { t.Helper() gin.SetMode(gin.TestMode) @@ -96,6 +113,8 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler messageRepo, &noopTypingIndicatorWidget{}, themeConfigRepo, preChatFormRepo, fileUploadRepo, offlineMsgRepo, inboxMemberRepo, tagRepo, campaignRepo, ) + transcriptDeliverer := &recordingWidgetTranscriptDeliverer{} + widgetSvc.SetTranscriptDeliverer(transcriptDeliverer) handler := NewHandler(widgetSvc) @@ -119,9 +138,17 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler chatwootWidgetGroup.POST("/messages", handler.SendMessage) chatwootWidgetGroup.PATCH("/messages/:message_id", handler.UpdateMessage) chatwootWidgetGroup.POST("/conversations", handler.CreateConversation) + chatwootWidgetGroup.POST("/conversations/destroy_custom_attributes", handler.DestroyConversationCustomAttributes) + chatwootWidgetGroup.POST("/conversations/set_custom_attributes", handler.SetConversationCustomAttributes) + chatwootWidgetGroup.POST("/conversations/toggle_typing", handler.ToggleTyping) + chatwootWidgetGroup.POST("/conversations/update_last_seen", handler.UpdateLastSeen) + chatwootWidgetGroup.GET("/conversations/toggle_status", handler.ToggleStatus) chatwootWidgetGroup.POST("/conversations/transcript", handler.SendTranscript) chatwootWidgetGroup.GET("/contact", handler.GetContact) + chatwootWidgetGroup.PATCH("/contact", handler.UpdateContact) + chatwootWidgetGroup.PUT("/contact", handler.UpdateContact) chatwootWidgetGroup.PATCH("/contact/set_user", handler.SetUser) + chatwootWidgetGroup.POST("/destroy_custom_attributes", handler.DestroyContactCustomAttributes) chatwootWidgetGroup.GET("/campaigns", handler.ListCampaigns) chatwootWidgetGroup.GET("/inbox_members", handler.ListInboxMembers) chatwootWidgetGroup.POST("/events", handler.CreateEvent) @@ -158,7 +185,7 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler } } - return db, router, handler + return db, router, handler, transcriptDeliverer } // seedWidgetHandlerData creates an account + web_widget inbox with known tokens. @@ -168,9 +195,10 @@ func seedWidgetHandlerData(t *testing.T, db *gorm.DB) (*model.Account, *model.In account := &model.Account{Name: "HandlerTestOrg", Locale: "en", Status: "active"} require.NoError(t, db.Create(account).Error) - widgetConfig := service.WebWidgetConfig{ - WebsiteToken: "handler_ws_token_123", - HMACToken: "handler_hmac_secret", + widgetConfig := map[string]interface{}{ + "website_token": "handler_ws_token_123", + "hmac_token": "handler_hmac_secret", + "selected_feature_flags": []string{"attachments", "emoji_picker", "end_conversation"}, } configJSON, err := json.Marshal(widgetConfig) require.NoError(t, err) @@ -214,6 +242,12 @@ func seedPublicAPIInbox(t *testing.T, db *gorm.DB) (*model.Account, *model.Inbox return account, inbox, channelAPI } +func hmacSHA256Hex(secret, message string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(message)) + return hex.EncodeToString(mac.Sum(nil)) +} + // ========== Init Handler Tests ========== func TestWidgetHandler_Init_Success(t *testing.T) { @@ -307,6 +341,164 @@ func TestWidgetHandler_ChatwootConfig_Success(t *testing.T) { assert.NotEmpty(t, contact["pubsub_token"]) } +func TestWidgetHandler_ChatwootConfig_InvalidWebsiteTokenReturnsNotFound(t *testing.T) { + _, router, _ := setupWidgetHandlerTest(t) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Empty(t, w.Body.String()) +} + +func TestWidgetHandler_ChatwootConfig_SuspendedAccountReturnsUnauthorized(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + require.NoError(t, db.Model(&model.Account{}).Where("id = ?", inbox.AccountID).Updates(map[string]any{"status": "suspended"}).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusUnauthorized, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "Account is suspended", resp["error"]) +} + +func TestWidgetHandler_ChatwootConfig_InvalidAuthTokenCreatesNewContact(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + + var before int64 + require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&before).Error) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + req.Header.Set("X-Auth-Token", "invalid token") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var after int64 + require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&after).Error) + assert.Equal(t, before+1, after) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Contains(t, resp, "website_channel_config") + assert.Contains(t, resp, "contact") + assert.Contains(t, resp, "global_config") +} + +func TestWidgetHandler_ChatwootMessageAppliesAttrsAndLabelsToNewConversation(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + account, _ := seedWidgetHandlerData(t, db) + require.NoError(t, db.Create(&model.Tag{AccountID: account.ID, Name: "vip"}).Error) + require.NoError(t, db.Create(&model.Tag{AccountID: account.ID, Name: "valid-label"}).Error) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + body, _ := json.Marshal(map[string]interface{}{ + "message": map[string]interface{}{"content": "hello world"}, + "custom_attributes": map[string]interface{}{"plan": "enterprise", "source": "website"}, + "labels": []string{"vip", "nonexistent", "valid-label", "vip"}, + }) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(body)) + reqMessage.Header.Set("Content-Type", "application/json") + reqMessage.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code) + + var conversation model.Conversation + require.NoError(t, db.First(&conversation).Error) + var attrs map[string]interface{} + require.NoError(t, json.Unmarshal(conversation.CustomAttributes, &attrs)) + assert.Equal(t, "enterprise", attrs["plan"]) + assert.Equal(t, "website", attrs["source"]) + assert.ElementsMatch(t, []string{"vip", "valid-label"}, strings.Split(conversation.Labels, ",")) +} + +func TestWidgetHandler_ChatwootMessageIgnoresAttrsAndLabelsForExistingConversation(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + account, _ := seedWidgetHandlerData(t, db) + require.NoError(t, db.Create(&model.Tag{AccountID: account.ID, Name: "vip"}).Error) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + firstBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "first"}}) + wFirst := httptest.NewRecorder() + reqFirst, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(firstBody)) + reqFirst.Header.Set("Content-Type", "application/json") + reqFirst.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wFirst, reqFirst) + require.Equal(t, http.StatusOK, wFirst.Code) + + secondBody, _ := json.Marshal(map[string]interface{}{ + "message": map[string]interface{}{"content": "second"}, + "custom_attributes": map[string]interface{}{"plan": "enterprise"}, + "labels": []string{"vip"}, + }) + wSecond := httptest.NewRecorder() + reqSecond, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(secondBody)) + reqSecond.Header.Set("Content-Type", "application/json") + reqSecond.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wSecond, reqSecond) + require.Equal(t, http.StatusOK, wSecond.Code) + + var conversation model.Conversation + require.NoError(t, db.First(&conversation).Error) + assert.Empty(t, strings.TrimSpace(conversation.Labels)) + var attrs map[string]interface{} + require.NoError(t, json.Unmarshal(conversation.CustomAttributes, &attrs)) + assert.NotContains(t, attrs, "plan") +} + +func TestWidgetHandler_ChatwootMessageRejectsTooLongContent(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, _ = seedWidgetHandlerData(t, db) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + body, _ := json.Marshal(map[string]interface{}{ + "message": map[string]interface{}{"content": strings.Repeat("h", 150001)}, + }) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(body)) + reqMessage.Header.Set("Content-Type", "application/json") + reqMessage.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusUnprocessableEntity, wMessage.Code) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &resp)) + assert.Equal(t, "Content is too long (maximum is 150000 characters)", resp["message"]) + + var messageCount int64 + require.NoError(t, db.Model(&model.Message{}).Count(&messageCount).Error) + assert.Zero(t, messageCount) + var conversationCount int64 + require.NoError(t, db.Model(&model.Conversation{}).Count(&conversationCount).Error) + assert.Zero(t, conversationCount) +} + func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _ = seedWidgetHandlerData(t, db) @@ -359,6 +551,130 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T) require.Equal(t, http.StatusOK, wContact.Code) } +func TestWidgetHandler_ChatwootMessagesIndexFiltersInternalMessages(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + authToken := createWidgetConversationWithMessage(t, router, "visible message") + + var conversation model.Conversation + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) + privateMessage := model.Message{ConversationID: conversation.ID, AccountID: conversation.AccountID, InboxID: conversation.InboxID, Content: "private note", ContentType: "text", MessageType: string(model.MessageTypeOutgoing), Private: true} + require.NoError(t, db.Create(&privateMessage).Error) + activityMessage := model.Message{ConversationID: conversation.ID, AccountID: conversation.AccountID, InboxID: conversation.InboxID, Content: "activity event", ContentType: "text", MessageType: string(model.MessageTypeActivity)} + require.NoError(t, db.Create(&activityMessage).Error) + + wIndex := httptest.NewRecorder() + reqIndex, _ := http.NewRequest("GET", "/api/v1/widget/messages", nil) + reqIndex.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wIndex, reqIndex) + require.Equal(t, http.StatusOK, wIndex.Code) + + var indexResp map[string]interface{} + require.NoError(t, json.Unmarshal(wIndex.Body.Bytes(), &indexResp)) + payload := indexResp["payload"].([]interface{}) + require.Len(t, payload, 1) + assert.Equal(t, "visible message", payload[0].(map[string]interface{})["content"]) + assert.Equal(t, float64(1), indexResp["meta"].(map[string]interface{})["total"]) +} + +func TestWidgetHandler_ChatwootMessagesIndexReturnsLatestTwenty(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + authToken := createWidgetConversationWithMessage(t, router, "seed message") + + var conversation model.Conversation + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) + require.NoError(t, db.Where("conversation_id = ?", conversation.ID).Delete(&model.Message{}).Error) + for i := 1; i <= 25; i++ { + message := model.Message{ConversationID: conversation.ID, AccountID: conversation.AccountID, InboxID: conversation.InboxID, Content: "message " + strconv.Itoa(i), ContentType: "text", MessageType: string(model.MessageTypeIncoming)} + require.NoError(t, db.Create(&message).Error) + } + + wIndex := httptest.NewRecorder() + reqIndex, _ := http.NewRequest("GET", "/api/v1/widget/messages", nil) + reqIndex.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wIndex, reqIndex) + require.Equal(t, http.StatusOK, wIndex.Code) + + var indexResp map[string]interface{} + require.NoError(t, json.Unmarshal(wIndex.Body.Bytes(), &indexResp)) + payload := indexResp["payload"].([]interface{}) + require.Len(t, payload, 20) + assert.Equal(t, "message 6", payload[0].(map[string]interface{})["content"]) + assert.Equal(t, "message 25", payload[19].(map[string]interface{})["content"]) + assert.Equal(t, float64(25), indexResp["meta"].(map[string]interface{})["total"]) +} + +func createWidgetConversationWithMessage(t *testing.T, router *gin.Engine, content string) string { + t.Helper() + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + bodyJSON, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": content}}) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(bodyJSON)) + reqMessage.Header.Set("Content-Type", "application/json") + reqMessage.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code) + return authToken +} + +func TestWidgetHandler_ChatwootMessageReplyToContentAttributes(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, _ = seedWidgetHandlerData(t, db) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + firstBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "first"}}) + wFirst := httptest.NewRecorder() + reqFirst, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(firstBody)) + reqFirst.Header.Set("Content-Type", "application/json") + reqFirst.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wFirst, reqFirst) + require.Equal(t, http.StatusOK, wFirst.Code) + var firstResp map[string]interface{} + require.NoError(t, json.Unmarshal(wFirst.Body.Bytes(), &firstResp)) + firstID := uint(firstResp["id"].(float64)) + + validReplyBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "reply", "reply_to": firstID}}) + wValidReply := httptest.NewRecorder() + reqValidReply, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(validReplyBody)) + reqValidReply.Header.Set("Content-Type", "application/json") + reqValidReply.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wValidReply, reqValidReply) + require.Equal(t, http.StatusOK, wValidReply.Code) + var validReplyResp map[string]interface{} + require.NoError(t, json.Unmarshal(wValidReply.Body.Bytes(), &validReplyResp)) + validAttrs := validReplyResp["content_attributes"].(map[string]interface{}) + assert.Equal(t, float64(firstID), validAttrs["in_reply_to"]) + assert.Nil(t, validAttrs["in_reply_to_external_id"]) + + invalidReplyBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "bad reply", "reply_to": firstID + 300}}) + wInvalidReply := httptest.NewRecorder() + reqInvalidReply, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(invalidReplyBody)) + reqInvalidReply.Header.Set("Content-Type", "application/json") + reqInvalidReply.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wInvalidReply, reqInvalidReply) + require.Equal(t, http.StatusOK, wInvalidReply.Code) + var invalidReplyResp map[string]interface{} + require.NoError(t, json.Unmarshal(wInvalidReply.Body.Bytes(), &invalidReplyResp)) + if invalidReplyResp["content_attributes"] != nil { + assert.Empty(t, invalidReplyResp["content_attributes"].(map[string]interface{})) + } +} + func TestWidgetHandler_ChatwootMessageDirectUploadAttachment(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, _ := seedWidgetHandlerData(t, db) @@ -430,6 +746,54 @@ func TestWidgetHandler_ChatwootMessageDirectUploadAttachment(t *testing.T) { assert.Equal(t, "/uploads/widget_direct/signed-widget-upload-1.png", indexedAttachments[0].(map[string]interface{})["data_url"]) } +func TestWidgetHandler_ChatwootMessageReopensSnoozedConversationWithoutActivity(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + authToken := createWidgetConversationWithMessage(t, router, "seed") + + var conversation model.Conversation + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) + snoozedUntil := time.Now().Add(time.Hour).Unix() + require.NoError(t, db.Model(&conversation).Updates(map[string]interface{}{"status": string(model.ConversationStatusSnoozed), "snoozed_until": snoozedUntil}).Error) + + bodyJSON, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "I am back"}}) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(bodyJSON)) + reqMessage.Header.Set("Content-Type", "application/json") + reqMessage.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code) + + require.NoError(t, db.First(&conversation, conversation.ID).Error) + assert.Equal(t, string(model.ConversationStatusOpen), conversation.Status) + assert.Nil(t, conversation.SnoozedUntil) + var activityCount int64 + require.NoError(t, db.Model(&model.Message{}).Where("conversation_id = ? AND message_type = ?", conversation.ID, string(model.MessageTypeActivity)).Count(&activityCount).Error) + assert.Zero(t, activityCount) +} + +func TestWidgetHandler_ChatwootMessageDoesNotReopenMutedResolvedConversation(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + authToken := createWidgetConversationWithMessage(t, router, "seed") + + var conversation model.Conversation + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) + require.NoError(t, db.Model(&conversation).Updates(map[string]interface{}{"status": string(model.ConversationStatusResolved), "muted": true}).Error) + + bodyJSON, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "hello muted"}}) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(bodyJSON)) + reqMessage.Header.Set("Content-Type", "application/json") + reqMessage.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code) + + require.NoError(t, db.First(&conversation, conversation.ID).Error) + assert.Equal(t, string(model.ConversationStatusResolved), conversation.Status) + assert.True(t, conversation.Muted) +} + func TestWidgetHandler_ChatwootMessageUpdate_SubmitsEmail(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) @@ -484,10 +848,40 @@ func TestWidgetHandler_ChatwootMessageUpdate_SubmitsEmail(t *testing.T) { var contact model.Contact require.NoError(t, db.First(&contact, conversation.ContactID).Error) assert.Equal(t, "visitor@example.test", contact.Email) + assert.Equal(t, "visitor", contact.Name) +} + +func TestWidgetHandler_ChatwootMessageUpdate_MergesExistingEmailContact(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + authToken := createWidgetConversationWithMessage(t, router, "start") + + var conversation model.Conversation + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) + originalContactID := conversation.ContactID + existing := &model.Contact{AccountID: inbox.AccountID, Name: "John Doe", Email: "existing@example.test", ContactType: "visitor"} + require.NoError(t, db.Create(existing).Error) + formMessage := &model.Message{ConversationID: conversation.ID, AccountID: conversation.AccountID, InboxID: conversation.InboxID, Content: "Email?", ContentType: "input_email", MessageType: "outgoing", ContentAttributes: datatypes.JSON(`{}`)} + require.NoError(t, db.Create(formMessage).Error) + + updateBody, _ := json.Marshal(map[string]interface{}{"contact": map[string]interface{}{"email": "Existing@Example.TEST"}}) + wUpdate := httptest.NewRecorder() + reqUpdate, _ := http.NewRequest("PATCH", "/api/v1/widget/messages/"+strconv.FormatUint(uint64(formMessage.ID), 10), bytes.NewReader(updateBody)) + reqUpdate.Header.Set("Content-Type", "application/json") + reqUpdate.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wUpdate, reqUpdate) + require.Equal(t, http.StatusOK, wUpdate.Code) + + require.NoError(t, db.First(&conversation, conversation.ID).Error) + assert.Equal(t, existing.ID, conversation.ContactID) + require.NoError(t, db.First(existing, existing.ID).Error) + assert.Equal(t, "John Doe", existing.Name) + var original model.Contact + assert.Error(t, db.First(&original, originalContactID).Error) } func TestWidgetHandler_ChatwootSetUserAndTranscript(t *testing.T) { - db, router, _ := setupWidgetHandlerTest(t) + db, router, _, transcriptDeliverer := setupWidgetHandlerTestWithTranscriptDeliverer(t) _, _ = seedWidgetHandlerData(t, db) wConfig := httptest.NewRecorder() @@ -534,22 +928,388 @@ func TestWidgetHandler_ChatwootSetUserAndTranscript(t *testing.T) { reqTranscript.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wTranscript, reqTranscript) require.Equal(t, http.StatusOK, wTranscript.Code) + assert.Empty(t, wTranscript.Body.String()) + require.Len(t, transcriptDeliverer.requests, 1) + assert.Equal(t, "external@example.test", transcriptDeliverer.requests[0].Recipient) + assert.Contains(t, transcriptDeliverer.requests[0].Body, "Need transcript") +} + +func TestWidgetHandler_ChatwootTranscriptStatusParity(t *testing.T) { + t.Run("without conversation returns empty too many requests", func(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, _ = seedWidgetHandlerData(t, db) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + wTranscript := httptest.NewRecorder() + reqTranscript, _ := http.NewRequest("POST", "/api/v1/widget/conversations/transcript", nil) + reqTranscript.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wTranscript, reqTranscript) + require.Equal(t, http.StatusTooManyRequests, wTranscript.Code) + assert.Empty(t, wTranscript.Body.String()) + }) + + t.Run("disabled transcript returns empty payment required", func(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + account, _ := seedWidgetHandlerData(t, db) + require.NoError(t, db.Model(account).Update("limits", datatypes.JSON(`{"email_transcript_enabled":false}`)).Error) + authToken := createWidgetConversationWithEmail(t, router, "visitor@example.test") + + wTranscript := httptest.NewRecorder() + reqTranscript, _ := http.NewRequest("POST", "/api/v1/widget/conversations/transcript", nil) + reqTranscript.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wTranscript, reqTranscript) + require.Equal(t, http.StatusPaymentRequired, wTranscript.Code) + assert.Empty(t, wTranscript.Body.String()) + }) + + t.Run("rate limited transcript returns empty too many requests", func(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + account, _ := seedWidgetHandlerData(t, db) + require.NoError(t, db.Model(account).Updates(map[string]any{ + "limits": datatypes.JSON(`{"emails":1}`), + "custom_attributes": datatypes.JSON(`{"_outbound_email_count":{"date":"` + time.Now().Format("2006-01-02") + `","count":1}}`), + }).Error) + authToken := createWidgetConversationWithEmail(t, router, "visitor@example.test") + + wTranscript := httptest.NewRecorder() + reqTranscript, _ := http.NewRequest("POST", "/api/v1/widget/conversations/transcript", nil) + reqTranscript.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wTranscript, reqTranscript) + require.Equal(t, http.StatusTooManyRequests, wTranscript.Code) + assert.Empty(t, wTranscript.Body.String()) + }) +} + +func TestWidgetHandler_ChatwootDestroyContactCustomAttributes(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, _ = seedWidgetHandlerData(t, db) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + setUserBody, _ := json.Marshal(map[string]interface{}{ + "identifier": "contact-attrs", + "custom_attributes": map[string]interface{}{"plan": "enterprise", "keep": "yes"}, + }) + wSetUser := httptest.NewRecorder() + reqSetUser, _ := http.NewRequest("PATCH", "/api/v1/widget/contact/set_user?website_token=handler_ws_token_123", bytes.NewReader(setUserBody)) + reqSetUser.Header.Set("Content-Type", "application/json") + reqSetUser.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wSetUser, reqSetUser) + require.Equal(t, http.StatusOK, wSetUser.Code) + + wDestroy := httptest.NewRecorder() + reqDestroy, _ := http.NewRequest("POST", "/api/v1/widget/destroy_custom_attributes?website_token=handler_ws_token_123", strings.NewReader(`{"custom_attributes":["plan"]}`)) + reqDestroy.Header.Set("Content-Type", "application/json") + reqDestroy.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wDestroy, reqDestroy) + require.Equal(t, http.StatusOK, wDestroy.Code) + + var contactResp map[string]interface{} + require.NoError(t, json.Unmarshal(wDestroy.Body.Bytes(), &contactResp)) + attrs := contactResp["custom_attributes"].(map[string]interface{}) + assert.NotContains(t, attrs, "plan") + assert.Equal(t, "yes", attrs["keep"]) + + var contact model.Contact + require.NoError(t, db.First(&contact, uint(contactResp["id"].(float64))).Error) + var storedAttrs map[string]interface{} + require.NoError(t, json.Unmarshal(contact.CustomAttributes, &storedAttrs)) + assert.NotContains(t, storedAttrs, "plan") + assert.Equal(t, "yes", storedAttrs["keep"]) +} + +func TestWidgetHandler_ChatwootDestroyContactCustomAttributesInvalidToken(t *testing.T) { + _, router, _ := setupWidgetHandlerTest(t) + + wDestroy := httptest.NewRecorder() + reqDestroy, _ := http.NewRequest("POST", "/api/v1/widget/destroy_custom_attributes?website_token=missing", strings.NewReader(`{"custom_attributes":["plan"]}`)) + reqDestroy.Header.Set("Content-Type", "application/json") + router.ServeHTTP(wDestroy, reqDestroy) + require.Equal(t, http.StatusNotFound, wDestroy.Code) + assert.Empty(t, wDestroy.Body.String()) +} + +func TestWidgetHandler_ChatwootContactActionsInvalidTokenReturnNotFound(t *testing.T) { + _, router, _ := setupWidgetHandlerTest(t) + + requests := []struct { + name string + method string + path string + body string + contentType string + authToken string + }{ + {name: "show without auth token", method: http.MethodGet, path: "/api/v1/widget/contact"}, + {name: "show invalid auth token", method: http.MethodGet, path: "/api/v1/widget/contact", authToken: "invalid-token"}, + {name: "update without auth token", method: http.MethodPatch, path: "/api/v1/widget/contact", body: `{"name":"Visitor"}`, contentType: "application/json"}, + {name: "update invalid auth token", method: http.MethodPatch, path: "/api/v1/widget/contact", body: `{"name":"Visitor"}`, contentType: "application/json", authToken: "invalid-token"}, + {name: "destroy attrs without auth token", method: http.MethodPost, path: "/api/v1/widget/destroy_custom_attributes", body: `{"custom_attributes":["plan"]}`, contentType: "application/json"}, + {name: "destroy attrs invalid auth token", method: http.MethodPost, path: "/api/v1/widget/destroy_custom_attributes", body: `{"custom_attributes":["plan"]}`, contentType: "application/json", authToken: "invalid-token"}, + } + + for _, request := range requests { + w := httptest.NewRecorder() + req, _ := http.NewRequest(request.method, request.path, strings.NewReader(request.body)) + if request.contentType != "" { + req.Header.Set("Content-Type", request.contentType) + } + if request.authToken != "" { + req.Header.Set("X-Auth-Token", request.authToken) + } + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code, request.name) + assert.Empty(t, w.Body.String(), request.name) + } +} + +func createWidgetConversationWithEmail(t *testing.T, router *gin.Engine, email string) string { + t.Helper() + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + identifier := strings.ReplaceAll(email, "@", "-") + hash := hmac.New(sha256.New, []byte("handler_hmac_secret")) + _, _ = hash.Write([]byte(identifier)) + identifierHash := hex.EncodeToString(hash.Sum(nil)) + setUserBody, _ := json.Marshal(map[string]interface{}{ + "identifier": identifier, + "identifier_hash": identifierHash, + "email": email, + "name": "Transcript Visitor", + }) + wSetUser := httptest.NewRecorder() + reqSetUser, _ := http.NewRequest("PATCH", "/api/v1/widget/contact/set_user?website_token=handler_ws_token_123", bytes.NewReader(setUserBody)) + reqSetUser.Header.Set("Content-Type", "application/json") + reqSetUser.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wSetUser, reqSetUser) + require.Equal(t, http.StatusOK, wSetUser.Code) + + messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Need transcript"}}) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(messageBody)) + reqMessage.Header.Set("Content-Type", "application/json") + reqMessage.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code) + return authToken +} + +func TestWidgetHandler_ChatwootConversationHeadActionsReturnEmptyOK(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, _ = seedWidgetHandlerData(t, db) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Need help"}}) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(messageBody)) + reqMessage.Header.Set("Content-Type", "application/json") + reqMessage.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code) + + requests := []struct { + method string + path string + body string + }{ + {http.MethodPost, "/api/v1/widget/conversations/toggle_typing", `{"typing_status":"on"}`}, + {http.MethodPost, "/api/v1/widget/conversations/update_last_seen", `{"contact_last_seen_at":1710000000}`}, + {http.MethodGet, "/api/v1/widget/conversations/toggle_status", ``}, + } + + for _, request := range requests { + w := httptest.NewRecorder() + req, _ := http.NewRequest(request.method, request.path, strings.NewReader(request.body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, request.path) + assert.Empty(t, w.Body.String(), request.path) + } + + var activity model.Message + require.NoError(t, db.Where("message_type = ?", string(model.MessageTypeActivity)).First(&activity).Error) + assert.Equal(t, "Conversation was resolved by Anonymous Visitor", activity.Content) +} + +func TestWidgetHandler_ChatwootToggleStatusHonorsEndConversationFlag(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + + var config map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(inbox.ChannelConfig), &config)) + config["selected_feature_flags"] = []string{"attachments", "emoji_picker"} + configJSON, err := json.Marshal(config) + require.NoError(t, err) + require.NoError(t, db.Model(inbox).Update("channel_config", string(configJSON)).Error) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Need help"}}) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(messageBody)) + reqMessage.Header.Set("Content-Type", "application/json") + reqMessage.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code) + + wToggle := httptest.NewRecorder() + reqToggle, _ := http.NewRequest("GET", "/api/v1/widget/conversations/toggle_status", nil) + reqToggle.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wToggle, reqToggle) + require.Equal(t, http.StatusForbidden, wToggle.Code) + assert.Empty(t, wToggle.Body.String()) + + var conversation model.Conversation + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) + assert.Equal(t, string(model.ConversationStatusOpen), conversation.Status) + + var activityCount int64 + require.NoError(t, db.Model(&model.Message{}).Where("message_type = ?", string(model.MessageTypeActivity)).Count(&activityCount).Error) + assert.Zero(t, activityCount) +} + +func TestWidgetHandler_ChatwootToggleStatusWithoutConversationReturnsNotFound(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, _ = seedWidgetHandlerData(t, db) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + wToggle := httptest.NewRecorder() + reqToggle, _ := http.NewRequest("GET", "/api/v1/widget/conversations/toggle_status", nil) + reqToggle.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wToggle, reqToggle) + require.Equal(t, http.StatusNotFound, wToggle.Code) + assert.Empty(t, wToggle.Body.String()) +} + +func TestWidgetHandler_ChatwootConversationCustomAttributeResponses(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, _ = seedWidgetHandlerData(t, db) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Need help"}}) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(messageBody)) + reqMessage.Header.Set("Content-Type", "application/json") + reqMessage.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code) + + wSet := httptest.NewRecorder() + reqSet, _ := http.NewRequest("POST", "/api/v1/widget/conversations/set_custom_attributes", strings.NewReader(`{"custom_attributes":{"plan":"pro"}}`)) + reqSet.Header.Set("Content-Type", "application/json") + reqSet.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wSet, reqSet) + require.Equal(t, http.StatusOK, wSet.Code) + assert.Empty(t, wSet.Body.String()) + + wDestroy := httptest.NewRecorder() + reqDestroy, _ := http.NewRequest("POST", "/api/v1/widget/conversations/destroy_custom_attributes", strings.NewReader(`{"custom_attribute":["plan"]}`)) + reqDestroy.Header.Set("Content-Type", "application/json") + reqDestroy.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wDestroy, reqDestroy) + require.Equal(t, http.StatusOK, wDestroy.Code) + assert.NotEmpty(t, wDestroy.Body.String()) + var conversation map[string]interface{} + require.NoError(t, json.Unmarshal(wDestroy.Body.Bytes(), &conversation)) + assert.NotContains(t, conversation["custom_attributes"].(map[string]interface{}), "plan") +} + +func TestWidgetHandler_ChatwootConversationActionsReturnNotFoundWithoutConversationContext(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, _ = seedWidgetHandlerData(t, db) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + requests := []struct { + name string + path string + body string + authToken string + contentType string + }{ + {name: "set custom attributes without auth token", path: "/api/v1/widget/conversations/set_custom_attributes", body: `{"custom_attributes":{"product_name":"Chatwoot"}}`, contentType: "application/json"}, + {name: "destroy custom attributes without auth token", path: "/api/v1/widget/conversations/destroy_custom_attributes", body: `{"custom_attribute":["product_name"]}`, contentType: "application/json"}, + {name: "update last seen without auth token", path: "/api/v1/widget/conversations/update_last_seen"}, + {name: "set custom attributes invalid auth token", path: "/api/v1/widget/conversations/set_custom_attributes", body: `{"custom_attributes":{"product_name":"Chatwoot"}}`, authToken: "invalid-token", contentType: "application/json"}, + {name: "destroy custom attributes invalid auth token", path: "/api/v1/widget/conversations/destroy_custom_attributes", body: `{"custom_attribute":["product_name"]}`, authToken: "invalid-token", contentType: "application/json"}, + {name: "update last seen invalid auth token", path: "/api/v1/widget/conversations/update_last_seen", authToken: "invalid-token"}, + {name: "set custom attributes before conversation", path: "/api/v1/widget/conversations/set_custom_attributes", body: `{"custom_attributes":{"product_name":"Chatwoot"}}`, authToken: authToken, contentType: "application/json"}, + {name: "destroy custom attributes before conversation", path: "/api/v1/widget/conversations/destroy_custom_attributes", body: `{"custom_attribute":["product_name"]}`, authToken: authToken, contentType: "application/json"}, + {name: "update last seen before conversation", path: "/api/v1/widget/conversations/update_last_seen", authToken: authToken}, + } + + for _, request := range requests { + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", request.path, strings.NewReader(request.body)) + if request.contentType != "" { + req.Header.Set("Content-Type", request.contentType) + } + if request.authToken != "" { + req.Header.Set("X-Auth-Token", request.authToken) + } + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code, request.name) + assert.Empty(t, w.Body.String(), request.name) + } } func TestWidgetHandler_ChatwootDyteParticipant(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) - contact := &model.Contact{AccountID: inbox.AccountID, Name: "Video Visitor"} - require.NoError(t, db.Create(contact).Error) - conversation := &model.Conversation{ - AccountID: inbox.AccountID, - InboxID: inbox.ID, - ContactID: contact.ID, - Status: "open", - ChannelType: inbox.ChannelType, - Channel: inbox.ChannelType, - } - require.NoError(t, db.Create(conversation).Error) + conversation, widgetToken := createWidgetDyteConversation(t, db, inbox, "Video Visitor") message := &model.Message{ ConversationID: conversation.ID, AccountID: inbox.AccountID, @@ -565,6 +1325,7 @@ func TestWidgetHandler_ChatwootDyteParticipant(t *testing.T) { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/widget/integrations/dyte/add_participant_to_meeting?website_token=handler_ws_token_123", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Auth-Token", widgetToken) router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) @@ -573,6 +1334,51 @@ func TestWidgetHandler_ChatwootDyteParticipant(t *testing.T) { assert.Equal(t, "dyte_meeting-123", resp["token"]) } +func TestWidgetHandler_ChatwootDyteParticipantRequiresWidgetToken(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + conversation, _ := createWidgetDyteConversation(t, db, inbox, "Video Visitor") + message := &model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "Join video", ContentType: "integrations", MessageType: "outgoing", ContentAttributes: datatypes.JSON(`{"data":{"meeting_id":"meeting-123"}}`)} + require.NoError(t, db.Create(message).Error) + + body, _ := json.Marshal(map[string]interface{}{"message_id": message.ID}) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/widget/integrations/dyte/add_participant_to_meeting?website_token=handler_ws_token_123", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestWidgetHandler_ChatwootDyteParticipantRejectsNonIntegrationMessage(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + conversation, widgetToken := createWidgetDyteConversation(t, db, inbox, "Video Visitor") + message := &model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "plain", ContentType: "text", MessageType: "outgoing"} + require.NoError(t, db.Create(message).Error) + + body, _ := json.Marshal(map[string]interface{}{"message_id": message.ID}) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/widget/integrations/dyte/add_participant_to_meeting?website_token=handler_ws_token_123", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Auth-Token", widgetToken) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusUnprocessableEntity, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "Invalid message type. Action not permitted", resp["error"]) +} + +func createWidgetDyteConversation(t *testing.T, db *gorm.DB, inbox *model.Inbox, contactName string) (*model.Conversation, string) { + t.Helper() + contact := &model.Contact{AccountID: inbox.AccountID, Name: contactName} + require.NoError(t, db.Create(contact).Error) + contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: contactName + "-source", PubsubToken: contactName + "-pubsub"} + require.NoError(t, db.Create(contactInbox).Error) + conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + require.NoError(t, db.Create(conversation).Error) + return conversation, contactInbox.PubsubToken +} + func TestWidgetHandler_ChatwootInboxMembers_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) @@ -607,9 +1413,20 @@ func TestWidgetHandler_ChatwootInboxMembers_Success(t *testing.T) { assert.Equal(t, "online", member["availability_status"]) } +func TestWidgetHandler_ChatwootInboxMembers_InvalidWebsiteTokenReturnsNotFound(t *testing.T) { + _, router, _ := setupWidgetHandlerTest(t) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/widget/inbox_members?website_token=", nil) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusNotFound, w.Code) + assert.Empty(t, w.Body.String()) +} + func TestWidgetHandler_ChatwootCampaigns_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) + require.NoError(t, db.Model(&model.Account{}).Where("id = ?", inbox.AccountID).Update("feature_flags", `{"campaigns":true}`).Error) require.NoError(t, db.Create(&campaign.Campaign{ AccountID: inbox.AccountID, @@ -647,6 +1464,25 @@ func TestWidgetHandler_ChatwootCampaigns_Success(t *testing.T) { assert.Equal(t, "https://example.test", rules["url"]) } +func TestWidgetHandler_ChatwootCampaigns_FeatureDisabledAndInvalidToken(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + require.NoError(t, db.Model(&model.Account{}).Where("id = ?", inbox.AccountID).Update("feature_flags", `{"campaigns":false}`).Error) + require.NoError(t, db.Create(&campaign.Campaign{AccountID: inbox.AccountID, InboxID: inbox.ID, DisplayID: 42, Title: "Welcome", Message: "Hidden while disabled", CampaignType: campaign.CampaignTypeOngoing, Enabled: true}).Error) + + wDisabled := httptest.NewRecorder() + reqDisabled, _ := http.NewRequest("GET", "/api/v1/widget/campaigns?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wDisabled, reqDisabled) + require.Equal(t, http.StatusOK, wDisabled.Code) + assert.JSONEq(t, `[]`, wDisabled.Body.String()) + + wInvalid := httptest.NewRecorder() + reqInvalid, _ := http.NewRequest("GET", "/api/v1/widget/campaigns?website_token=", nil) + router.ServeHTTP(wInvalid, reqInvalid) + require.Equal(t, http.StatusNotFound, wInvalid.Code) + assert.Empty(t, wInvalid.Body.String()) +} + func TestWidgetHandler_ChatwootEventsAndLabels(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) @@ -670,9 +1506,20 @@ func TestWidgetHandler_ChatwootEventsAndLabels(t *testing.T) { router.ServeHTTP(wMessage, reqMessage) require.Equal(t, http.StatusOK, wMessage.Code) + wUndefinedAdd := httptest.NewRecorder() + undefinedLabelBody, _ := json.Marshal(map[string]interface{}{"label": "missing-label"}) + reqUndefinedAdd, _ := http.NewRequest("POST", "/api/v1/widget/labels", bytes.NewReader(undefinedLabelBody)) + reqUndefinedAdd.Header.Set("Content-Type", "application/json") + reqUndefinedAdd.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wUndefinedAdd, reqUndefinedAdd) + require.Equal(t, http.StatusNoContent, wUndefinedAdd.Code) + var conversation model.Conversation + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) + assert.Empty(t, conversation.Labels) + wEvent := httptest.NewRecorder() - eventBody, _ := json.Marshal(map[string]interface{}{"name": "widget.opened", "event_info": map[string]interface{}{"source": "test"}}) - reqEvent, _ := http.NewRequest("POST", "/api/v1/widget/events?website_token=handler_ws_token_123", bytes.NewReader(eventBody)) + eventBody, _ := json.Marshal(map[string]interface{}{"website_token": "handler_ws_token_123", "name": "widget.opened", "event_info": map[string]interface{}{"source": "test"}}) + reqEvent, _ := http.NewRequest("POST", "/api/v1/widget/events", bytes.NewReader(eventBody)) reqEvent.Header.Set("Content-Type", "application/json") reqEvent.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wEvent, reqEvent) @@ -686,7 +1533,6 @@ func TestWidgetHandler_ChatwootEventsAndLabels(t *testing.T) { router.ServeHTTP(wAdd, reqAdd) require.Equal(t, http.StatusNoContent, wAdd.Code) - var conversation model.Conversation require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) assert.Equal(t, "vip", conversation.Labels) @@ -700,6 +1546,61 @@ func TestWidgetHandler_ChatwootEventsAndLabels(t *testing.T) { assert.Empty(t, conversation.Labels) } +func TestWidgetHandler_ChatwootEvents_InvalidWebsiteTokenReturnsNotFound(t *testing.T) { + _, router, _ := setupWidgetHandlerTest(t) + + body, _ := json.Marshal(map[string]interface{}{"website_token": "", "name": "webwidget.triggered", "event_info": map[string]interface{}{"test_id": "test"}}) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/widget/events", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusNotFound, w.Code) + assert.Empty(t, w.Body.String()) +} + +func TestWidgetHandler_ChatwootLabelsReturnNotFoundWithoutConversationContext(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, _ = seedWidgetHandlerData(t, db) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + requests := []struct { + name string + method string + path string + body string + authToken string + contentType string + }{ + {name: "add without auth token", method: http.MethodPost, path: "/api/v1/widget/labels", body: `{"label":"vip"}`, contentType: "application/json"}, + {name: "remove without auth token", method: http.MethodDelete, path: "/api/v1/widget/labels/vip"}, + {name: "add invalid auth token", method: http.MethodPost, path: "/api/v1/widget/labels", body: `{"label":"vip"}`, authToken: "invalid-token", contentType: "application/json"}, + {name: "remove invalid auth token", method: http.MethodDelete, path: "/api/v1/widget/labels/vip", authToken: "invalid-token"}, + {name: "add before conversation", method: http.MethodPost, path: "/api/v1/widget/labels", body: `{"label":"vip"}`, authToken: authToken, contentType: "application/json"}, + {name: "remove before conversation", method: http.MethodDelete, path: "/api/v1/widget/labels/vip", authToken: authToken}, + } + + for _, request := range requests { + w := httptest.NewRecorder() + req, _ := http.NewRequest(request.method, request.path, strings.NewReader(request.body)) + if request.contentType != "" { + req.Header.Set("Content-Type", request.contentType) + } + if request.authToken != "" { + req.Header.Set("X-Auth-Token", request.authToken) + } + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code, request.name) + assert.Empty(t, w.Body.String(), request.name) + } +} + // ========== SendMessage Handler Tests ========== func TestWidgetHandler_SendMessage_Success(t *testing.T) { @@ -1200,7 +2101,7 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T require.NoError(t, json.Unmarshal(w.Body.Bytes(), &inboxResp)) assert.Equal(t, "public-api-inbox", inboxResp["identifier"]) assert.Equal(t, inbox.Name, inboxResp["name"]) - assert.Equal(t, true, inboxResp["identity_validation_enabled"]) + assert.Equal(t, false, inboxResp["identity_validation_enabled"]) contactBody := map[string]any{ "source_id": "public-source-1", @@ -1289,6 +2190,270 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T assert.Equal(t, http.StatusOK, w.Code) } +func TestWidgetHandler_PublicAPIConversationsFilterInternalMessages(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox, _ := seedPublicAPIInbox(t, db) + contact := &model.Contact{AccountID: inbox.AccountID, Name: "Public Filter Visitor"} + require.NoError(t, db.Create(contact).Error) + contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "filter-source", PubsubToken: "filter-token"} + require.NoError(t, db.Create(contactInbox).Error) + displayID := uint(77) + conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, DisplayID: &displayID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + require.NoError(t, db.Create(conversation).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "visible-1", MessageType: string(model.MessageTypeIncoming)}).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "private-hidden", MessageType: string(model.MessageTypeIncoming), Private: true}).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "activity-hidden", MessageType: string(model.MessageTypeActivity)}).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "visible-2", MessageType: string(model.MessageTypeOutgoing)}).Error) + + list := httptest.NewRecorder() + listReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/filter-source/conversations", nil) + router.ServeHTTP(list, listReq) + require.Equal(t, http.StatusOK, list.Code) + var conversations []map[string]any + require.NoError(t, json.Unmarshal(list.Body.Bytes(), &conversations)) + require.Len(t, conversations, 1) + messages := conversations[0]["messages"].([]interface{}) + require.Len(t, messages, 2) + contents := []string{messages[0].(map[string]any)["content"].(string), messages[1].(map[string]any)["content"].(string)} + assert.ElementsMatch(t, []string{"visible-1", "visible-2"}, contents) + + show := httptest.NewRecorder() + showReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/filter-source/conversations/77", nil) + router.ServeHTTP(show, showReq) + require.Equal(t, http.StatusOK, show.Code) + var conversationResp map[string]any + require.NoError(t, json.Unmarshal(show.Body.Bytes(), &conversationResp)) + showMessages := conversationResp["messages"].([]interface{}) + require.Len(t, showMessages, 2) + showContents := []string{showMessages[0].(map[string]any)["content"].(string), showMessages[1].(map[string]any)["content"].(string)} + assert.ElementsMatch(t, []string{"visible-1", "visible-2"}, showContents) +} + +func TestWidgetHandler_PublicAPIHMACVerifiedContactCanAccessContactConversations(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + account, inbox, _ := seedPublicAPIInbox(t, db) + otherInbox := &model.Inbox{AccountID: account.ID, Name: "Other Public API Inbox", ChannelType: "api", ChannelID: 2, Enabled: true, Timezone: "UTC"} + require.NoError(t, db.Create(otherInbox).Error) + require.NoError(t, db.Create(&channelmodel.ChannelAPI{InboxID: otherInbox.ID, Identifier: "other-public-api-inbox", HMACToken: "other_hmac_secret"}).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Verified Public Visitor", Identifier: "verified-public-visitor"} + require.NoError(t, db.Create(contact).Error) + contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "verified-source", PubsubToken: "verified-token", HMACVerified: true} + require.NoError(t, db.Create(contactInbox).Error) + otherContactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: otherInbox.ID, SourceID: "other-source", PubsubToken: "other-token"} + require.NoError(t, db.Create(otherContactInbox).Error) + displayID := uint(88) + conversation := &model.Conversation{AccountID: account.ID, InboxID: otherInbox.ID, ContactID: contact.ID, ContactInboxID: &otherContactInbox.ID, DisplayID: &displayID, Status: "open", ChannelType: otherInbox.ChannelType, Channel: otherInbox.ChannelType} + require.NoError(t, db.Create(conversation).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: account.ID, InboxID: otherInbox.ID, Content: "cross-inbox-visible", MessageType: string(model.MessageTypeIncoming)}).Error) + + list := httptest.NewRecorder() + listReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/verified-source/conversations", nil) + router.ServeHTTP(list, listReq) + require.Equal(t, http.StatusOK, list.Code) + var conversations []map[string]any + require.NoError(t, json.Unmarshal(list.Body.Bytes(), &conversations)) + require.Len(t, conversations, 1) + assert.Equal(t, float64(displayID), conversations[0]["id"]) + + show := httptest.NewRecorder() + showReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/verified-source/conversations/88", nil) + router.ServeHTTP(show, showReq) + require.Equal(t, http.StatusOK, show.Code) + var showResp map[string]any + require.NoError(t, json.Unmarshal(show.Body.Bytes(), &showResp)) + assert.Equal(t, float64(displayID), showResp["id"]) + messages := showResp["messages"].([]interface{}) + require.Len(t, messages, 1) + assert.Equal(t, "cross-inbox-visible", messages[0].(map[string]any)["content"]) +} + +func TestWidgetHandler_PublicAPIHMACMandatoryRequiresIdentifierHash(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, _, channelAPI := seedPublicAPIInbox(t, db) + channelAPI.HMACMandatory = true + require.NoError(t, db.Save(channelAPI).Error) + + showInbox := httptest.NewRecorder() + showInboxReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox", nil) + router.ServeHTTP(showInbox, showInboxReq) + require.Equal(t, http.StatusOK, showInbox.Code) + var inboxResp map[string]any + require.NoError(t, json.Unmarshal(showInbox.Body.Bytes(), &inboxResp)) + assert.Equal(t, true, inboxResp["identity_validation_enabled"]) + + missingHash := httptest.NewRecorder() + missingHashReq, _ := http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts", bytes.NewReader([]byte(`{"source_id":"mandatory-source","identifier":"mandatory-user"}`))) + missingHashReq.Header.Set("Content-Type", "application/json") + router.ServeHTTP(missingHash, missingHashReq) + require.Equal(t, http.StatusUnauthorized, missingHash.Code) + assert.Contains(t, missingHash.Body.String(), "HMAC failed") + + validHash := hmacSHA256Hex(channelAPI.HMACToken, "mandatory-user") + validBody, err := json.Marshal(map[string]any{"source_id": "mandatory-source", "identifier": "mandatory-user", "identifier_hash": validHash}) + require.NoError(t, err) + created := httptest.NewRecorder() + createdReq, _ := http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts", bytes.NewReader(validBody)) + createdReq.Header.Set("Content-Type", "application/json") + router.ServeHTTP(created, createdReq) + require.Equal(t, http.StatusOK, created.Code) + + var contactInbox model.ContactInbox + require.NoError(t, db.Where("source_id = ?", "mandatory-source").First(&contactInbox).Error) + assert.True(t, contactInbox.HMACVerified) +} + +func TestWidgetHandler_PublicAPIMessageRejectsOversizedContent(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox, _ := seedPublicAPIInbox(t, db) + contact := &model.Contact{AccountID: inbox.AccountID, Name: "Oversized Public Visitor"} + require.NoError(t, db.Create(contact).Error) + contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "oversized-source", PubsubToken: "oversized-token"} + require.NoError(t, db.Create(contactInbox).Error) + conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + require.NoError(t, db.Create(conversation).Error) + + body, err := json.Marshal(map[string]any{"content": strings.Repeat("h", 150001)}) + require.NoError(t, err) + w := httptest.NewRecorder() + url := "/public/api/v1/inboxes/public-api-inbox/contacts/oversized-source/conversations/" + strconv.FormatUint(uint64(conversation.ID), 10) + "/messages" + req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusUnprocessableEntity, w.Code) + assert.Contains(t, w.Body.String(), "Content is too long (maximum is 150000 characters)") + + var count int64 + require.NoError(t, db.Model(&model.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error) + assert.Zero(t, count) +} + +func TestWidgetHandler_PublicAPIMessageCreatesAttachmentFromSignedUpload(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + account, inbox, _ := seedPublicAPIInbox(t, db) + contact := &model.Contact{AccountID: inbox.AccountID, Name: "Attachment Public Visitor"} + require.NoError(t, db.Create(contact).Error) + contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "attachment-source", PubsubToken: "attachment-token"} + require.NoError(t, db.Create(contactInbox).Error) + conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + require.NoError(t, db.Create(conversation).Error) + upload := &model.DirectUpload{ + UploadUUID: "public-signed-upload-1", + AccountID: account.ID, + Status: model.DirectUploadStatusPending, + Source: model.DirectUploadSourceWidget, + OriginalName: "public.png", + FileType: "image", + MimeType: "image/png", + FileSize: 13, + FileURL: "/uploads/widget_direct/public-signed-upload-1.png", + ThumbURL: "/uploads/widget_direct/public-signed-upload-1.png", + ExpiresAt: time.Now().Add(time.Hour), + } + require.NoError(t, db.Create(upload).Error) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + require.NoError(t, writer.WriteField("content", "hello")) + require.NoError(t, writer.WriteField("attachments[]", upload.UploadUUID)) + require.NoError(t, writer.Close()) + + w := httptest.NewRecorder() + url := "/public/api/v1/inboxes/public-api-inbox/contacts/attachment-source/conversations/" + strconv.FormatUint(uint64(conversation.ID), 10) + "/messages" + req, _ := http.NewRequest("POST", url, body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + attachments := resp["attachments"].([]interface{}) + require.Len(t, attachments, 1) + assert.Equal(t, "image", attachments[0].(map[string]any)["file_type"]) + assert.Equal(t, "/uploads/widget_direct/public-signed-upload-1.png", attachments[0].(map[string]any)["data_url"]) + + var attachment model.Attachment + require.NoError(t, db.Where("file_name = ?", "public.png").First(&attachment).Error) + assert.Equal(t, account.ID, attachment.AccountID) + require.NoError(t, db.First(upload, upload.ID).Error) + assert.Equal(t, model.DirectUploadStatusCompleted, upload.Status) + + list := httptest.NewRecorder() + listReq, _ := http.NewRequest("GET", url, nil) + router.ServeHTTP(list, listReq) + require.Equal(t, http.StatusOK, list.Code) + var listResp []map[string]any + require.NoError(t, json.Unmarshal(list.Body.Bytes(), &listResp)) + require.Len(t, listResp, 1) + listedAttachments := listResp[0]["attachments"].([]interface{}) + require.Len(t, listedAttachments, 1) + assert.Equal(t, "/uploads/widget_direct/public-signed-upload-1.png", listedAttachments[0].(map[string]any)["data_url"]) + + show := httptest.NewRecorder() + showReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/attachment-source/conversations/"+strconv.FormatUint(uint64(conversation.ID), 10), nil) + router.ServeHTTP(show, showReq) + require.Equal(t, http.StatusOK, show.Code) + var showResp map[string]any + require.NoError(t, json.Unmarshal(show.Body.Bytes(), &showResp)) + showMessages := showResp["messages"].([]interface{}) + require.Len(t, showMessages, 1) + showAttachments := showMessages[0].(map[string]any)["attachments"].([]interface{}) + require.Len(t, showAttachments, 1) + assert.Equal(t, "/uploads/widget_direct/public-signed-upload-1.png", showAttachments[0].(map[string]any)["data_url"]) + + conversationList := httptest.NewRecorder() + conversationListReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/attachment-source/conversations", nil) + router.ServeHTTP(conversationList, conversationListReq) + require.Equal(t, http.StatusOK, conversationList.Code) + var conversationListResp []map[string]any + require.NoError(t, json.Unmarshal(conversationList.Body.Bytes(), &conversationListResp)) + require.Len(t, conversationListResp, 1) + conversationMessages := conversationListResp[0]["messages"].([]interface{}) + require.Len(t, conversationMessages, 1) + conversationAttachments := conversationMessages[0].(map[string]any)["attachments"].([]interface{}) + require.Len(t, conversationAttachments, 1) + assert.Equal(t, "/uploads/widget_direct/public-signed-upload-1.png", conversationAttachments[0].(map[string]any)["data_url"]) +} + +func TestWidgetHandler_PublicAPIMessageIndexUsesMessageFinderBeforeWindow(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox, _ := seedPublicAPIInbox(t, db) + contact := &model.Contact{AccountID: inbox.AccountID, Name: "Window Public Visitor"} + require.NoError(t, db.Create(contact).Error) + contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "window-source", PubsubToken: "window-token"} + require.NoError(t, db.Create(contactInbox).Error) + conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + require.NoError(t, db.Create(conversation).Error) + messageIDs := make([]uint, 0, 25) + for index := 1; index <= 25; index++ { + message := &model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: fmt.Sprintf("visible-%02d", index), MessageType: string(model.MessageTypeIncoming)} + require.NoError(t, db.Create(message).Error) + messageIDs = append(messageIDs, message.ID) + } + require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "private-hidden", MessageType: string(model.MessageTypeIncoming), Private: true}).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "activity-hidden", MessageType: string(model.MessageTypeActivity)}).Error) + + latest := httptest.NewRecorder() + latestURL := "/public/api/v1/inboxes/public-api-inbox/contacts/window-source/conversations/" + strconv.FormatUint(uint64(conversation.ID), 10) + "/messages" + latestReq, _ := http.NewRequest("GET", latestURL, nil) + router.ServeHTTP(latest, latestReq) + require.Equal(t, http.StatusOK, latest.Code) + var latestMessages []map[string]any + require.NoError(t, json.Unmarshal(latest.Body.Bytes(), &latestMessages)) + require.Len(t, latestMessages, 20) + assert.Equal(t, "visible-06", latestMessages[0]["content"]) + assert.Equal(t, "visible-25", latestMessages[19]["content"]) + + before := httptest.NewRecorder() + beforeURL := latestURL + "?before=" + strconv.FormatUint(uint64(messageIDs[20]), 10) + beforeReq, _ := http.NewRequest("GET", beforeURL, nil) + router.ServeHTTP(before, beforeReq) + require.Equal(t, http.StatusOK, before.Code) + var beforeMessages []map[string]any + require.NoError(t, json.Unmarshal(before.Body.Bytes(), &beforeMessages)) + require.Len(t, beforeMessages, 20) + assert.Equal(t, "visible-01", beforeMessages[0]["content"]) + assert.Equal(t, "visible-20", beforeMessages[19]["content"]) +} + func TestWidgetHandler_PublicAPIMessageUpdate_CsatSubmission(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, inbox, _ := seedPublicAPIInbox(t, db) diff --git a/internal/model/account.go b/internal/model/account.go index 90a74d26..c2a13944 100644 --- a/internal/model/account.go +++ b/internal/model/account.go @@ -25,6 +25,9 @@ type Account struct { InboxLimit int `gorm:"default:0" json:"inbox_limit,omitempty"` // max inboxes allowed (0 = unlimited), Chatwoot usage_limits[:inboxes] CaptainModels datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_models,omitempty"` CaptainFeatures datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_features,omitempty"` + // Chatwoot: store_accessor :settings, :keep_pending_on_bot_failure + // When true, agent bot webhook failures do NOT reopen pending conversations. + KeepPendingOnBotFailure bool `gorm:"default:false" json:"keep_pending_on_bot_failure,omitempty"` } func (Account) TableName() string { return "accounts" } diff --git a/internal/model/channel/api.go b/internal/model/channel/api.go index 7b196bf1..164fc8b4 100644 --- a/internal/model/channel/api.go +++ b/internal/model/channel/api.go @@ -4,22 +4,26 @@ import ( "time" "github.com/gochat/gochat/internal/model" + "gorm.io/datatypes" ) // ChannelAPI represents a REST API channel configuration. // Reference: Chatwoot Channel::Api + P2B M2 spec type ChannelAPI struct { - ID uint `gorm:"primaryKey" json:"id"` - InboxID uint `gorm:"uniqueIndex;not null" json:"inbox_id"` - WebhookURL string `gorm:"size:512" json:"webhook_url"` - HMACToken string `gorm:"size:255" json:"hmac_token"` - Identifier string `gorm:"size:255" json:"identifier"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + InboxID uint `gorm:"uniqueIndex;not null" json:"inbox_id"` + WebhookURL string `gorm:"size:512" json:"webhook_url"` + Secret string `gorm:"size:255" json:"secret"` + HMACToken string `gorm:"size:255" json:"hmac_token"` + HMACMandatory bool `gorm:"column:hmac_mandatory;default:false" json:"hmac_mandatory"` + AdditionalAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"additional_attributes"` + Identifier string `gorm:"size:255" json:"identifier"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Inbox model.Inbox `gorm:"foreignKey:InboxID" json:"inbox,omitempty"` } -func (ChannelAPI) TableName() string { return "channel_api" } -func (c ChannelAPI) GetInboxID() uint { return c.InboxID } -func (c ChannelAPI) GetChannelType() model.InboxChannelType { return model.InboxChannelTypeAPI } \ No newline at end of file +func (ChannelAPI) TableName() string { return "channel_api" } +func (c ChannelAPI) GetInboxID() uint { return c.InboxID } +func (c ChannelAPI) GetChannelType() model.InboxChannelType { return model.InboxChannelTypeAPI } diff --git a/internal/model/conversation.go b/internal/model/conversation.go index 680ff4e6..ab5169cf 100644 --- a/internal/model/conversation.go +++ b/internal/model/conversation.go @@ -19,6 +19,7 @@ type Conversation struct { ContactID uint `gorm:"index;not null" json:"contact_id"` ContactInboxID *uint `gorm:"index" json:"contact_inbox_id,omitempty"` AssigneeID *uint `gorm:"index" json:"assignee_id"` + AssigneeAgentBotID *uint `gorm:"index" json:"assignee_agent_bot_id,omitempty"` TeamID *uint `gorm:"index" json:"team_id,omitempty"` CampaignID *uint `gorm:"index" json:"campaign_id,omitempty"` SlaPolicyID *uint `gorm:"index" json:"sla_policy_id,omitempty"` diff --git a/internal/repository/agent_bot_repo.go b/internal/repository/agent_bot_repo.go index 90763963..4fdfdfe1 100644 --- a/internal/repository/agent_bot_repo.go +++ b/internal/repository/agent_bot_repo.go @@ -14,6 +14,14 @@ type AgentBotRepo struct { db *gorm.DB } +// DB returns the underlying gorm.DB for advanced query building. +func (r *AgentBotRepo) DB() *gorm.DB { + if r == nil { + return nil + } + return r.db +} + // NewAgentBotRepo creates a new AgentBot repository. func NewAgentBotRepo(db *gorm.DB) *AgentBotRepo { return &AgentBotRepo{db: db} diff --git a/internal/router/router.go b/internal/router/router.go index a9a48b41..3fed67d6 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -2045,6 +2045,7 @@ func registerChatwootWidgetRoutes(g *gin.RouterGroup, h *Handlers) { g.GET("/contact", h.Widget.GetContact) g.PUT("/contact", h.Widget.UpdateContact) g.PATCH("/contact", h.Widget.UpdateContact) + g.POST("/destroy_custom_attributes", h.Widget.DestroyContactCustomAttributes) g.POST("/contact/destroy_custom_attributes", h.Widget.DestroyContactCustomAttributes) g.PATCH("/contact/set_user", h.Widget.SetUser) diff --git a/internal/service/agent_bot_listener.go b/internal/service/agent_bot_listener.go index 237bc9f4..79d59915 100644 --- a/internal/service/agent_bot_listener.go +++ b/internal/service/agent_bot_listener.go @@ -10,11 +10,15 @@ import ( "fmt" "io" "net/http" + "strings" "time" + "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/webhookutil" applogger "github.com/gochat/gochat/pkg/logger" + "github.com/google/uuid" ) // AgentBotListener is the webhook push engine that consumes events @@ -23,27 +27,101 @@ import ( // with HMAC-SHA256 signature using bot.secret for verification. // // Architecture mapping: -// Chatwoot AgentBotListener (Wisper observer) → Watermill subscriber + HTTP push -// Chatwoot webhook_signature → HMAC-SHA256 using bot.secret +// +// Chatwoot AgentBotListener (Wisper observer) → Watermill subscriber + HTTP push +// Chatwoot webhook_signature → HMAC-SHA256 using bot.secret // // Event flow: -// 1. Conversation/message event published to Watermill topic -// 2. AgentBotListener receives event -// 3. Finds all active AgentBotInbox bindings for the event's inbox -// 4. For each binding, POSTs the event payload to bot.outgoing_url -// 5. Signs the payload with HMAC-SHA256 using bot.secret +// 1. Conversation/message event published to Watermill topic +// 2. AgentBotListener receives event +// 3. Finds all active AgentBotInbox bindings for the event's inbox +// 4. For each binding, POSTs the event payload to bot.outgoing_url +// 5. Signs the payload with HMAC-SHA256 using bot.secret type AgentBotListener struct { - botInboxRepo *repository.AgentBotInboxRepo - botRepo *repository.AgentBotRepo - httpClient *http.Client + botInboxRepo *repository.AgentBotInboxRepo + botRepo *repository.AgentBotRepo + conversationRepo *repository.ConversationRepo + messageRepo *repository.MessageRepo + httpClient *http.Client } // NewAgentBotListener creates a new AgentBotListener. -func NewAgentBotListener(botInboxRepo *repository.AgentBotInboxRepo, botRepo *repository.AgentBotRepo) *AgentBotListener { +func NewAgentBotListener(botInboxRepo *repository.AgentBotInboxRepo, botRepo *repository.AgentBotRepo, conversationRepo *repository.ConversationRepo, messageRepo *repository.MessageRepo) *AgentBotListener { return &AgentBotListener{ - botInboxRepo: botInboxRepo, - botRepo: botRepo, - httpClient: &http.Client{Timeout: 30 * time.Second}, + botInboxRepo: botInboxRepo, + botRepo: botRepo, + conversationRepo: conversationRepo, + messageRepo: messageRepo, + httpClient: &http.Client{Timeout: webhookutil.Timeout(context.Background(), botRepo.DB())}, + } +} + +// Name returns the dispatcher listener identifier. +func (l *AgentBotListener) Name() string { return "agent_bot" } + +// OnEvent adapts channel dispatcher events to Chatwoot agent bot event names. +func (l *AgentBotListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error { + if event == nil { + return nil + } + eventName := agentBotEventName(event.Type) + if eventName == "" { + return nil + } + if isMessageWebhookEvent(eventName) && !isAgentBotMessageSendable(event) { + return nil + } + return l.HandleEvent(ctx, eventName, event.AccountID, event.InboxID, event.Data) +} + +// isAgentBotMessageSendable checks Chatwoot message.webhook_sendable? for agent bot +// delivery. Only incoming/outgoing/template messages should be sent to agent bots; +// activity messages, private notes, and CSAT input messages are excluded. +func isAgentBotMessageSendable(event *channel.ChannelEvent) bool { + if event == nil || event.Data == nil { + return false + } + msgType := "" + if msg, ok := event.Data["message"]; ok { + switch typed := msg.(type) { + case map[string]interface{}: + if t, ok := typed["message_type"].(string); ok { + msgType = t + } + case *model.Message: + msgType = typed.MessageType + case model.Message: + msgType = typed.MessageType + } + } + if msgType == "" { + // Can't determine type — allow delivery (fallback). + return true + } + switch msgType { + case "incoming", "outgoing", "template": + return true + default: + return false + } +} + +func agentBotEventName(eventType channel.EventType) string { + switch eventType { + case channel.EventMessageCreated: + return "message_created" + case channel.EventMessageUpdated: + return "message_updated" + case channel.EventConversationUpdated: + return "conversation_updated" + case channel.EventConversationOpened: + return "conversation_opened" + case channel.EventConversationResolved: + return "conversation_resolved" + case channel.EventWebwidgetTriggered: + return "webwidget_triggered" + default: + return "" } } @@ -64,7 +142,7 @@ func (l *AgentBotListener) HandleEvent(ctx context.Context, eventType string, ac applogger.L().Infof("AgentBotListener: handling event %s for account %d, inbox %d", eventType, accountID, inboxID) // Find all active bot-inbox bindings for this inbox - bindings, err := l.botInboxRepo.FindActiveByInboxID(ctx, inboxID) + bindings, err := l.agentBotBindingsForEvent(ctx, inboxID, data) if err != nil { applogger.L().Errorf("AgentBotListener: find active bindings for inbox %d: %v", inboxID, err) return err @@ -75,15 +153,7 @@ func (l *AgentBotListener) HandleEvent(ctx context.Context, eventType string, ac return nil } - payload := WebhookPayload{ - Event: eventType, - AccountID: accountID, - InboxID: inboxID, - Timestamp: time.Now().UTC(), - Data: data, - } - - payloadBytes, err := json.Marshal(payload) + payloadBytes, err := json.Marshal(agentBotWebhookPayload(eventType, accountID, inboxID, data)) if err != nil { applogger.L().Errorf("AgentBotListener: marshal payload: %v", err) return fmt.Errorf("failed to marshal webhook payload: %w", err) @@ -91,9 +161,19 @@ func (l *AgentBotListener) HandleEvent(ctx context.Context, eventType string, ac // Push to each bound bot for _, binding := range bindings { - if err := l.pushToBot(ctx, binding, payloadBytes); err != nil { - applogger.L().Errorf("AgentBotListener: push to bot %d failed: %v", binding.AgentBotID, err) - // Continue pushing to other bots even if one fails + pushErr := l.pushToBot(ctx, binding, payloadBytes) + if pushErr != nil { + applogger.L().Errorf("AgentBotListener: push to bot %d failed: %v", binding.AgentBotID, pushErr) + // Reference: Chatwoot Webhooks::Trigger — retryable 429/500 errors skip + // conversation reopen and let the worker retry; non-retryable errors reopen + // pending conversations for message_created/message_updated. + if isRetryableAgentBotError(pushErr) { + applogger.L().Warnf("AgentBotListener: retryable error for bot %d, deferring reopen", binding.AgentBotID) + } else if isMessageWebhookEvent(eventType) { + if reopenErr := l.reopenPendingConversationFromEvent(ctx, eventType, data); reopenErr != nil { + applogger.L().Warnf("AgentBotListener: failed to reopen pending conversation: %v", reopenErr) + } + } continue } applogger.L().Infof("AgentBotListener: pushed event %s to bot %d at inbox %d", eventType, binding.AgentBotID, inboxID) @@ -102,9 +182,81 @@ func (l *AgentBotListener) HandleEvent(ctx context.Context, eventType string, ac return nil } -// pushToBot sends the webhook payload to a single bot's outgoing_url with HMAC signature. -// Reference: Chatwoot computes HMAC-SHA256 of payload using bot.secret, -// then sends as X-Signature header for the receiving endpoint to verify. +func (l *AgentBotListener) agentBotBindingsForEvent(ctx context.Context, inboxID uint, data map[string]interface{}) ([]model.AgentBotInbox, error) { + bindings, err := l.botInboxRepo.FindActiveByInboxID(ctx, inboxID) + if err != nil { + return nil, err + } + assignedBotID, err := l.assignedAgentBotIDFromEvent(ctx, data) + if err != nil { + return nil, err + } + if assignedBotID == 0 { + return bindings, nil + } + seen := make(map[uint]bool, len(bindings)+1) + for _, binding := range bindings { + seen[binding.AgentBotID] = true + } + if seen[assignedBotID] { + return bindings, nil + } + bindings = append(bindings, model.AgentBotInbox{AgentBotID: assignedBotID, InboxID: inboxID, Status: model.AgentBotInboxActive}) + return bindings, nil +} + +func (l *AgentBotListener) assignedAgentBotIDFromEvent(ctx context.Context, data map[string]interface{}) (uint, error) { + if data == nil { + return 0, nil + } + if id := extractUintFromMap(data, "assignee_agent_bot_id"); id != 0 { + return id, nil + } + if conversationData, ok := data["conversation"].(map[string]interface{}); ok { + if id := extractUintFromMap(conversationData, "assignee_agent_bot_id"); id != 0 { + return id, nil + } + } + if l.conversationRepo == nil { + return 0, nil + } + conversationID := conversationIDFromEventData(data) + if conversationID == 0 { + return 0, nil + } + conversation, err := l.conversationRepo.FindByID(ctx, conversationID) + if err != nil { + return 0, err + } + if conversation.AssigneeAgentBotID == nil { + return 0, nil + } + return *conversation.AssigneeAgentBotID, nil +} + +func conversationIDFromEventData(data map[string]interface{}) uint { + if conv, ok := data["conversation"].(map[string]interface{}); ok { + return extractUintFromMap(conv, "id") + } + return 0 +} + +func agentBotWebhookPayload(eventType string, accountID uint, inboxID uint, data map[string]interface{}) map[string]interface{} { + payload := make(map[string]interface{}, len(data)+3) + for key, value := range data { + payload[key] = value + } + payload["event"] = eventType + if _, ok := payload["account_id"]; !ok { + payload["account_id"] = accountID + } + if _, ok := payload["inbox_id"]; !ok { + payload["inbox_id"] = inboxID + } + return payload +} + +// pushToBot sends the webhook payload to a single bot's outgoing_url with Chatwoot webhook headers. func (l *AgentBotListener) pushToBot(ctx context.Context, binding model.AgentBotInbox, payload []byte) error { // Fetch the bot details bot, err := l.botRepo.FindByID(ctx, binding.AgentBotID) @@ -117,10 +269,6 @@ func (l *AgentBotListener) pushToBot(ctx context.Context, binding model.AgentBot return nil } - // Compute HMAC-SHA256 signature using bot.secret - // Reference: Chatwoot webhook_signature — HMAC digest of raw payload - signature := computeHMAC(payload, bot.Secret) - // Build the HTTP request req, err := http.NewRequestWithContext(ctx, http.MethodPost, bot.OutgoingURL, bytes.NewReader(payload)) if err != nil { @@ -128,9 +276,17 @@ func (l *AgentBotListener) pushToBot(ctx context.Context, binding model.AgentBot } req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Signature", signature) - req.Header.Set("X-Agent-Bot-ID", fmt.Sprintf("%d", bot.ID)) - req.Header.Set("X-Agent-Bot-Token", bot.AccessToken) + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Chatwoot-Delivery", uuid.NewString()) + if bot.Secret != "" { + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + req.Header.Set("X-Chatwoot-Timestamp", timestamp) + req.Header.Set("X-Chatwoot-Signature", computeChatwootHMAC(payload, bot.Secret, timestamp)) + } + + if l.httpClient == nil { + l.httpClient = &http.Client{Timeout: webhookutil.Timeout(ctx, l.botRepo.DB())} + } // Execute the request resp, err := l.httpClient.Do(req) @@ -150,13 +306,130 @@ func (l *AgentBotListener) pushToBot(ctx context.Context, binding model.AgentBot return nil } -// computeHMAC computes HMAC-SHA256 signature of the payload using the secret key. -// Reference: Chatwoot AgentBots::WebhookSignature — OpenSSL::HMAC.hexdigest('sha256', secret, payload) -func computeHMAC(payload []byte, secret string) string { +// computeChatwootHMAC computes Chatwoot's sha256 HMAC over timestamp.body. +func computeChatwootHMAC(payload []byte, secret, timestamp string) string { if secret == "" { return "" } mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(timestamp)) + mac.Write([]byte(".")) mac.Write(payload) - return hex.EncodeToString(mac.Sum(nil)) -} \ No newline at end of file + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +func isMessageWebhookEvent(eventType string) bool { + return eventType == "message_created" || eventType == "message_updated" +} + +// reopenPendingConversationFromEvent reopens a pending conversation when an agent bot +// webhook fails for message_created/message_updated events, matching Chatwoot +// Webhooks::Trigger#update_conversation_status behavior. +func (l *AgentBotListener) reopenPendingConversationFromEvent(ctx context.Context, eventType string, data map[string]interface{}) error { + if l.conversationRepo == nil { + return nil + } + var conversationID uint + if conv, ok := data["conversation"]; ok { + if convMap, ok := conv.(map[string]interface{}); ok { + if id, ok := convMap["id"]; ok { + switch v := id.(type) { + case float64: + conversationID = uint(v) + case uint: + conversationID = v + case int: + conversationID = uint(v) + } + } + } + } + if conversationID == 0 { + messageID := extractUintFromMap(data, "id") + if messageID == 0 { + if msg, ok := data["message"].(map[string]interface{}); ok { + messageID = extractUintFromMap(msg, "id") + } + } + if messageID == 0 || l.messageRepo == nil { + return nil + } + message, err := l.messageRepo.FindByID(ctx, messageID) + if err != nil { + return err + } + conversationID = message.ConversationID + if conversationID == 0 { + return nil + } + } + conversation, err := l.conversationRepo.FindByID(ctx, conversationID) + if err != nil { + return err + } + if conversation.Status != string(model.ConversationStatusPending) { + return nil + } + // Reference: Chatwoot Webhooks::Trigger#update_conversation_status + // Skips reopen when the account has keep_pending_on_bot_failure enabled. + var account model.Account + if err := l.conversationRepo.DB().WithContext(ctx).First(&account, conversation.AccountID).Error; err == nil && account.KeepPendingOnBotFailure { + applogger.L().Infof("AgentBotListener: skipping reopen for conversation %d due to keep_pending_on_bot_failure", conversation.ID) + return nil + } + if err := l.conversationRepo.UpdateStatus(ctx, conversation.ID, model.ConversationStatusOpen); err != nil { + return err + } + // Reference: Chatwoot Webhooks::Trigger#create_agent_bot_error_activity + // Creates an activity message when a pending conversation is reopened due to bot failure. + if l.messageRepo != nil { + activityMsg := &model.Message{ + AccountID: conversation.AccountID, + ConversationID: conversation.ID, + InboxID: conversation.InboxID, + Content: "Conversation was reopened due to agent bot webhook failure", + MessageType: "activity", + ContentType: "text", + } + if err := l.messageRepo.Create(ctx, activityMsg); err != nil { + applogger.L().Warnf("AgentBotListener: failed to create activity message for conversation %d: %v", conversation.ID, err) + } + } + applogger.L().Infof("AgentBotListener: reopened pending conversation %d after %s webhook failure", conversation.ID, eventType) + return nil +} + +func extractUintFromMap(data map[string]interface{}, key string) uint { + if data == nil { + return 0 + } + switch v := data[key].(type) { + case float64: + return uint(v) + case uint: + return v + case int: + return uint(v) + case json.Number: + parsed, err := v.Int64() + if err == nil && parsed > 0 { + return uint(parsed) + } + } + return 0 +} + +// isRetryableAgentBotError checks if an agent bot webhook error is retryable, +// matching Chatwoot Webhooks::Trigger#retryable_agent_bot_error? for 429/500 statuses. +func isRetryableAgentBotError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + for _, status := range []int{429, 500} { + if strings.Contains(msg, fmt.Sprintf("status %d", status)) { + return true + } + } + return false +} diff --git a/internal/service/agent_bot_listener_test.go b/internal/service/agent_bot_listener_test.go new file mode 100644 index 00000000..8ea638d3 --- /dev/null +++ b/internal/service/agent_bot_listener_test.go @@ -0,0 +1,537 @@ +package service + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gochat/gochat/internal/channel" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" +) + +func newAgentBotListenerTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:agent-bot-listener?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.AgentBot{}, &model.AgentBotInbox{}, &model.Conversation{}, &model.Message{}, &model.InstallationConfig{})) + t.Cleanup(func() { sqlDB.Close() }) + return db +} + +func TestAgentBotListenerReopensPendingConversationOnWebhookFailure(t *testing.T) { + db := newAgentBotListenerTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + account := &model.Account{Name: "Test", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "default", OutgoingURL: server.URL, Secret: "secret", AccessToken: "token"} + require.NoError(t, db.Create(bot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, Status: string(model.ConversationStatusPending), ChannelType: "api"} + require.NoError(t, db.Create(conversation).Error) + + msgRepo := repository.NewMessageRepo(db) + botInboxRepo := repository.NewAgentBotInboxRepo(db) + botRepo := repository.NewAgentBotRepo(db) + convRepo := repository.NewConversationRepo(db) + + listener := NewAgentBotListener(botInboxRepo, botRepo, convRepo, msgRepo) + err := listener.HandleEvent(context.Background(), "message_created", account.ID, inbox.ID, map[string]interface{}{ + "conversation": map[string]interface{}{"id": float64(conversation.ID)}, + "message": map[string]interface{}{"id": float64(1)}, + }) + require.NoError(t, err) + + var updated model.Conversation + require.NoError(t, db.First(&updated, conversation.ID).Error) + assert.Equal(t, string(model.ConversationStatusOpen), updated.Status) + + var activity model.Message + require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").First(&activity).Error) + assert.Contains(t, activity.Content, "webhook failure") +} + +func TestAgentBotListenerSendsChatwootStyleTopLevelPayload(t *testing.T) { + db := newAgentBotListenerTestDB(t) + var received map[string]interface{} + var receivedBody []byte + var receivedSignature string + var receivedTimestamp string + var receivedDelivery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + receivedBody = body + receivedSignature = r.Header.Get("X-Chatwoot-Signature") + receivedTimestamp = r.Header.Get("X-Chatwoot-Timestamp") + receivedDelivery = r.Header.Get("X-Chatwoot-Delivery") + assert.Empty(t, r.Header.Get("X-Signature")) + assert.Empty(t, r.Header.Get("X-Agent-Bot-ID")) + assert.Empty(t, r.Header.Get("X-Agent-Bot-Token")) + assert.Equal(t, "application/json", r.Header.Get("Accept")) + require.NoError(t, json.Unmarshal(body, &received)) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + account := &model.Account{Name: "Payload", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "default", OutgoingURL: server.URL, Secret: "secret", AccessToken: "token"} + require.NoError(t, db.Create(bot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + + listener := NewAgentBotListener(repository.NewAgentBotInboxRepo(db), repository.NewAgentBotRepo(db), nil, nil) + err := listener.HandleEvent(context.Background(), "message_created", account.ID, inbox.ID, map[string]interface{}{ + "id": float64(123), + "content": "hello bot", + "conversation": map[string]interface{}{ + "id": float64(456), + }, + }) + require.NoError(t, err) + + require.NotNil(t, received) + assert.Equal(t, "message_created", received["event"]) + assert.Equal(t, float64(account.ID), received["account_id"]) + assert.Equal(t, float64(inbox.ID), received["inbox_id"]) + assert.Equal(t, float64(123), received["id"]) + assert.Equal(t, "hello bot", received["content"]) + assert.NotContains(t, received, "data") + assert.NotContains(t, received, "timestamp") + assert.NotEmpty(t, receivedDelivery) + assert.Regexp(t, `^\d+$`, receivedTimestamp) + mac := hmac.New(sha256.New, []byte("secret")) + mac.Write([]byte(receivedTimestamp)) + mac.Write([]byte(".")) + mac.Write(receivedBody) + assert.Equal(t, "sha256="+hex.EncodeToString(mac.Sum(nil)), receivedSignature) +} + +func TestAgentBotListenerSendsToInboxAndAssignedAgentBots(t *testing.T) { + db := newAgentBotListenerTestDB(t) + deliveries := make(chan map[string]interface{}, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]interface{} + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &payload)) + deliveries <- payload + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + account := &model.Account{Name: "Assigned", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + inboxBot := &model.AgentBot{AccountID: &account.ID, Name: "Inbox Bot", BotType: "default", OutgoingURL: server.URL, Secret: "inbox-secret", AccessToken: "inbox-token"} + require.NoError(t, db.Create(inboxBot).Error) + assignedBot := &model.AgentBot{AccountID: &account.ID, Name: "Assigned Bot", BotType: "default", OutgoingURL: server.URL, Secret: "assigned-secret", AccessToken: "assigned-token"} + require.NoError(t, db.Create(assignedBot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: inboxBot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: "api", AssigneeAgentBotID: &assignedBot.ID} + require.NoError(t, db.Create(conversation).Error) + + listener := NewAgentBotListener(repository.NewAgentBotInboxRepo(db), repository.NewAgentBotRepo(db), repository.NewConversationRepo(db), nil) + err := listener.HandleEvent(context.Background(), "message_created", account.ID, inbox.ID, map[string]interface{}{ + "conversation": map[string]interface{}{"id": float64(conversation.ID)}, + }) + require.NoError(t, err) + + for i := 0; i < 2; i++ { + select { + case payload := <-deliveries: + assert.Equal(t, "message_created", payload["event"]) + case <-time.After(time.Second): + t.Fatalf("expected delivery %d", i+1) + } + } + assert.Empty(t, deliveries) +} + +func TestAgentBotListenerDoesNotDuplicateAssignedInboxBot(t *testing.T) { + db := newAgentBotListenerTestDB(t) + deliveryCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + deliveryCount++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + account := &model.Account{Name: "Dedup", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "default", OutgoingURL: server.URL, Secret: "secret", AccessToken: "token"} + require.NoError(t, db.Create(bot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: "api", AssigneeAgentBotID: &bot.ID} + require.NoError(t, db.Create(conversation).Error) + + listener := NewAgentBotListener(repository.NewAgentBotInboxRepo(db), repository.NewAgentBotRepo(db), repository.NewConversationRepo(db), nil) + err := listener.HandleEvent(context.Background(), "message_created", account.ID, inbox.ID, map[string]interface{}{ + "conversation": map[string]interface{}{"id": float64(conversation.ID)}, + }) + require.NoError(t, err) + assert.Equal(t, 1, deliveryCount) +} + +func TestAgentBotListenerOnEventSkipsActivityMessages(t *testing.T) { + db := newAgentBotListenerTestDB(t) + deliveryCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + deliveryCount++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + account := &model.Account{Name: "Activity", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "default", OutgoingURL: server.URL, Secret: "secret", AccessToken: "token"} + require.NoError(t, db.Create(bot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + + listener := NewAgentBotListener(repository.NewAgentBotInboxRepo(db), repository.NewAgentBotRepo(db), nil, nil) + + // Activity message should be skipped + err := listener.OnEvent(context.Background(), &channel.ChannelEvent{ + Type: channel.EventMessageCreated, + AccountID: account.ID, + InboxID: inbox.ID, + Data: map[string]interface{}{ + "message": map[string]interface{}{"id": float64(1), "message_type": "activity"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, 0, deliveryCount) + + // Outgoing message should be delivered + err = listener.OnEvent(context.Background(), &channel.ChannelEvent{ + Type: channel.EventMessageCreated, + AccountID: account.ID, + InboxID: inbox.ID, + Data: map[string]interface{}{ + "message": map[string]interface{}{"id": float64(2), "message_type": "outgoing"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, 1, deliveryCount) +} + +func TestAgentBotListenerOnEventDispatchesSupportedChannelEvent(t *testing.T) { + db := newAgentBotListenerTestDB(t) + var received map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &received)) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + account := &model.Account{Name: "Dispatch", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "default", OutgoingURL: server.URL, Secret: "secret", AccessToken: "token"} + require.NoError(t, db.Create(bot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + + listener := NewAgentBotListener(repository.NewAgentBotInboxRepo(db), repository.NewAgentBotRepo(db), nil, nil) + err := listener.OnEvent(context.Background(), &channel.ChannelEvent{ + Type: channel.EventConversationUpdated, + AccountID: account.ID, + InboxID: inbox.ID, + Data: map[string]interface{}{ + "changed_attributes": []map[string]interface{}{{"status": map[string]interface{}{"previous_value": "open", "current_value": "pending"}}}, + }, + }) + require.NoError(t, err) + + require.NotNil(t, received) + assert.Equal(t, "conversation_updated", received["event"]) + assert.Contains(t, received, "changed_attributes") +} + +func TestAgentBotListenerOnEventMapsConversationOpenedAndResolved(t *testing.T) { + db := newAgentBotListenerTestDB(t) + var received map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &received)) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + account := &model.Account{Name: "EventName", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "default", OutgoingURL: server.URL, Secret: "secret", AccessToken: "token"} + require.NoError(t, db.Create(bot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: "api"} + require.NoError(t, db.Create(conversation).Error) + + listener := NewAgentBotListener(repository.NewAgentBotInboxRepo(db), repository.NewAgentBotRepo(db), nil, nil) + err := listener.OnEvent(context.Background(), &channel.ChannelEvent{ + Type: channel.EventConversationResolved, + AccountID: account.ID, + InboxID: inbox.ID, + Data: map[string]interface{}{ + "conversation": map[string]interface{}{"id": float64(conversation.ID)}, + }, + }) + require.NoError(t, err) + require.NotNil(t, received) + assert.Equal(t, "conversation_resolved", received["event"]) + + received = nil + err = listener.OnEvent(context.Background(), &channel.ChannelEvent{ + Type: channel.EventConversationOpened, + AccountID: account.ID, + InboxID: inbox.ID, + Data: map[string]interface{}{ + "conversation": map[string]interface{}{"id": float64(conversation.ID)}, + }, + }) + require.NoError(t, err) + require.NotNil(t, received) + assert.Equal(t, "conversation_opened", received["event"]) +} + +func TestAgentBotListenerSkipsReopenForNonPendingConversation(t *testing.T) { + db := newAgentBotListenerTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + account := &model.Account{Name: "Test2", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "default", OutgoingURL: server.URL, Secret: "secret", AccessToken: "token"} + require.NoError(t, db.Create(bot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: "api"} + require.NoError(t, db.Create(conversation).Error) + + botInboxRepo := repository.NewAgentBotInboxRepo(db) + botRepo := repository.NewAgentBotRepo(db) + convRepo := repository.NewConversationRepo(db) + + listener := NewAgentBotListener(botInboxRepo, botRepo, convRepo, nil) + err := listener.HandleEvent(context.Background(), "message_created", account.ID, inbox.ID, map[string]interface{}{ + "conversation": map[string]interface{}{"id": float64(conversation.ID)}, + }) + require.NoError(t, err) + + var updated model.Conversation + require.NoError(t, db.First(&updated, conversation.ID).Error) + assert.Equal(t, string(model.ConversationStatusOpen), updated.Status) +} +func TestAgentBotListenerSkipsReopenForRetryableStatus(t *testing.T) { + db := newAgentBotListenerTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer server.Close() + + account := &model.Account{Name: "Test3", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "default", OutgoingURL: server.URL, Secret: "secret", AccessToken: "token"} + require.NoError(t, db.Create(bot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, Status: string(model.ConversationStatusPending), ChannelType: "api"} + require.NoError(t, db.Create(conversation).Error) + + botInboxRepo := repository.NewAgentBotInboxRepo(db) + botRepo := repository.NewAgentBotRepo(db) + convRepo := repository.NewConversationRepo(db) + msgRepo := repository.NewMessageRepo(db) + + listener := NewAgentBotListener(botInboxRepo, botRepo, convRepo, msgRepo) + err := listener.HandleEvent(context.Background(), "message_created", account.ID, inbox.ID, map[string]interface{}{ + "conversation": map[string]interface{}{"id": float64(conversation.ID)}, + }) + require.NoError(t, err) + + var updated model.Conversation + require.NoError(t, db.First(&updated, conversation.ID).Error) + assert.Equal(t, string(model.ConversationStatusPending), updated.Status, "conversation should remain pending for retryable 429 error") + + var count int64 + db.Model(&model.Message{}).Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").Count(&count) + assert.Equal(t, int64(0), count, "no activity message should be created for retryable error") +} +func TestAgentBotListenerSkipsReopenWhenKeepPendingOnBotFailureEnabled(t *testing.T) { + db := newAgentBotListenerTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + account := &model.Account{Name: "Test4", Locale: "en", Active: true, KeepPendingOnBotFailure: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "default", OutgoingURL: server.URL, Secret: "secret", AccessToken: "token"} + require.NoError(t, db.Create(bot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, Status: string(model.ConversationStatusPending), ChannelType: "api"} + require.NoError(t, db.Create(conversation).Error) + + botInboxRepo := repository.NewAgentBotInboxRepo(db) + botRepo := repository.NewAgentBotRepo(db) + convRepo := repository.NewConversationRepo(db) + msgRepo := repository.NewMessageRepo(db) + + listener := NewAgentBotListener(botInboxRepo, botRepo, convRepo, msgRepo) + err := listener.HandleEvent(context.Background(), "message_created", account.ID, inbox.ID, map[string]interface{}{ + "conversation": map[string]interface{}{"id": float64(conversation.ID)}, + }) + require.NoError(t, err) + + var updated model.Conversation + require.NoError(t, db.First(&updated, conversation.ID).Error) + assert.Equal(t, string(model.ConversationStatusPending), updated.Status, "conversation should remain pending when keep_pending_on_bot_failure is enabled") + + var count int64 + db.Model(&model.Message{}).Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").Count(&count) + assert.Equal(t, int64(0), count, "no activity message should be created when keep_pending_on_bot_failure is enabled") +} + +func TestAgentBotListenerReopensPendingConversationFromMessageIDOnWebhookFailure(t *testing.T) { + db := newAgentBotListenerTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + account := &model.Account{Name: "Test5", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "default", OutgoingURL: server.URL, Secret: "secret", AccessToken: "token"} + require.NoError(t, db.Create(bot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, Status: string(model.ConversationStatusPending), ChannelType: "api"} + require.NoError(t, db.Create(conversation).Error) + message := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, Content: "hello", MessageType: "incoming", Status: "sent"} + require.NoError(t, db.Create(message).Error) + + listener := NewAgentBotListener( + repository.NewAgentBotInboxRepo(db), + repository.NewAgentBotRepo(db), + repository.NewConversationRepo(db), + repository.NewMessageRepo(db), + ) + err := listener.HandleEvent(context.Background(), "message_created", account.ID, inbox.ID, map[string]interface{}{ + "id": float64(message.ID), + }) + require.NoError(t, err) + + var updated model.Conversation + require.NoError(t, db.First(&updated, conversation.ID).Error) + assert.Equal(t, string(model.ConversationStatusOpen), updated.Status) + + var activity model.Message + require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").First(&activity).Error) + assert.Contains(t, activity.Content, "webhook failure") +} + +func TestAgentBotListenerKeepsPendingFromMessageIDWhenSettingEnabled(t *testing.T) { + db := newAgentBotListenerTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + account := &model.Account{Name: "Test6", Locale: "en", Active: true, KeepPendingOnBotFailure: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Bot Inbox", ChannelType: "api"} + require.NoError(t, db.Create(inbox).Error) + bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot", BotType: "default", OutgoingURL: server.URL, Secret: "secret", AccessToken: "token"} + require.NoError(t, db.Create(bot).Error) + require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, Status: string(model.ConversationStatusPending), ChannelType: "api"} + require.NoError(t, db.Create(conversation).Error) + message := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, Content: "hello", MessageType: "incoming", Status: "sent"} + require.NoError(t, db.Create(message).Error) + + listener := NewAgentBotListener( + repository.NewAgentBotInboxRepo(db), + repository.NewAgentBotRepo(db), + repository.NewConversationRepo(db), + repository.NewMessageRepo(db), + ) + err := listener.HandleEvent(context.Background(), "message_created", account.ID, inbox.ID, map[string]interface{}{ + "id": float64(message.ID), + }) + require.NoError(t, err) + + var updated model.Conversation + require.NoError(t, db.First(&updated, conversation.ID).Error) + assert.Equal(t, string(model.ConversationStatusPending), updated.Status) + + var count int64 + db.Model(&model.Message{}).Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").Count(&count) + assert.Equal(t, int64(0), count) +} + +func TestAgentBotListenerUsesChatwootDefaultTimeout(t *testing.T) { + listener := NewAgentBotListener(nil, nil, nil, nil) + + require.NotNil(t, listener.httpClient) + assert.Equal(t, 5*time.Second, listener.httpClient.Timeout) +} + +func TestAgentBotListenerUsesConfiguredWebhookTimeout(t *testing.T) { + db := newAgentBotListenerTestDB(t) + require.NoError(t, db.Create(&model.InstallationConfig{Name: "WEBHOOK_TIMEOUT", Value: "8"}).Error) + + listener := NewAgentBotListener(nil, repository.NewAgentBotRepo(db), nil, nil) + + require.NotNil(t, listener.httpClient) + assert.Equal(t, 8*time.Second, listener.httpClient.Timeout) +} + +func TestAgentBotListenerFallsBackForBlankConfiguredWebhookTimeout(t *testing.T) { + db := newAgentBotListenerTestDB(t) + require.NoError(t, db.Create(&model.InstallationConfig{Name: "WEBHOOK_TIMEOUT", Value: ""}).Error) + + listener := NewAgentBotListener(nil, repository.NewAgentBotRepo(db), nil, nil) + + require.NotNil(t, listener.httpClient) + assert.Equal(t, 5*time.Second, listener.httpClient.Timeout) +} diff --git a/internal/service/conversation_maintenance_worker.go b/internal/service/conversation_maintenance_worker.go index a0a6227f..4ea6350d 100644 --- a/internal/service/conversation_maintenance_worker.go +++ b/internal/service/conversation_maintenance_worker.go @@ -105,7 +105,13 @@ func RegisterConversationMaintenanceSearchIndexer(wp *worker.WorkerPool, db *gor if wp == nil || db == nil { return } - runner := &conversationMaintenanceRunner{wp: wp, db: db, now: time.Now, searchIndexer: indexer} + now := time.Now + if existing, ok := conversationMaintenanceRegistrations.Load(wp); ok { + if runner, ok := existing.(*conversationMaintenanceRunner); ok && runner.now != nil { + now = runner.now + } + } + runner := &conversationMaintenanceRunner{wp: wp, db: db, now: now, searchIndexer: indexer} wp.Register(TaskTypeConversationReopenSnoozed, runner.performReopenSnoozed) wp.Register(TaskTypeConversationResolutionForAccount, runner.performResolutionForAccount) wp.Register(TaskTypeConversationUpdateMessageStatus, runner.performUpdateMessageStatus) @@ -117,10 +123,10 @@ func registerConversationMaintenanceJobsWithNow(wp *worker.WorkerPool, db *gorm. if wp == nil || db == nil { return } - if _, loaded := conversationMaintenanceRegistrations.LoadOrStore(wp, struct{}{}); loaded { + runner := &conversationMaintenanceRunner{wp: wp, db: db, now: now} + if _, loaded := conversationMaintenanceRegistrations.LoadOrStore(wp, runner); loaded { return } - runner := &conversationMaintenanceRunner{wp: wp, db: db, now: now} wp.Register(TaskTypeScheduledTriggerItems, runner.performScheduledTriggerItems) wp.Register(TaskTypeCampaignTriggerOneoff, runner.performCampaignTriggerOneoff) wp.Register(TaskTypeConversationReopenSnoozed, runner.performReopenSnoozed) diff --git a/internal/service/inbox_service.go b/internal/service/inbox_service.go index 8697fb92..21b0f91e 100644 --- a/internal/service/inbox_service.go +++ b/internal/service/inbox_service.go @@ -19,6 +19,7 @@ import ( "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" + "gorm.io/gorm" ) const InboxLimitExceededMessage = "Account limit exceeded. Upgrade to a higher plan" @@ -220,6 +221,11 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb return nil, err } } + if inbox.ChannelType == "api" { + if err := s.syncAPIChannel(ctx, inbox); err != nil { + return nil, err + } + } if err := s.repo.Update(ctx, inbox); err != nil { applogger.L().Errorf("Failed to persist inbox defaults: %v", err) return nil, err @@ -282,6 +288,11 @@ func (s *InboxService) Update(ctx context.Context, accountID, id uint, req Updat inbox.WebhookURL = webhookURL } } + if inbox.ChannelType == "api" { + if err := s.syncAPIChannel(ctx, inbox); err != nil { + return nil, err + } + } if len(req.WorkingHours) > 0 { if err := s.updateInboxWorkingHours(ctx, inbox, req.WorkingHours); err != nil { return nil, err @@ -294,6 +305,64 @@ func (s *InboxService) Update(ctx context.Context, accountID, id uint, req Updat return inbox, nil } +func (s *InboxService) syncAPIChannel(ctx context.Context, inbox *model.Inbox) error { + if s == nil || s.repo == nil || s.repo.DB() == nil || inbox == nil || inbox.ChannelType != "api" { + return nil + } + config := parseChannelConfigMap(inbox.ChannelConfig) + channelAPI := channelmodel.ChannelAPI{} + err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error + if err != nil && err != gorm.ErrRecordNotFound { + return err + } + if err == gorm.ErrRecordNotFound { + channelAPI.InboxID = inbox.ID + } + channelAPI.Secret = firstNonEmpty(channelAPI.Secret, inbox.Secret, mapString(config, "secret"), generateInboxSecret()) + channelAPI.Identifier = firstNonEmpty(mapString(config, "identifier"), mapString(config, "inbox_identifier"), generateInboxSecret()) + channelAPI.HMACToken = firstNonEmpty(mapString(config, "hmac_token"), generateInboxSecret()) + channelAPI.HMACMandatory = mapBool(config, "hmac_mandatory") + channelAPI.WebhookURL = firstNonEmpty(mapString(config, "webhook_url"), inbox.WebhookURL) + if err := validateChannelAPIAdditionalAttributes(config["additional_attributes"]); err != nil { + return err + } + channelAPI.AdditionalAttributes = marshalChannelAPIAdditionalAttributes(config["additional_attributes"]) + if err := s.repo.DB().WithContext(ctx).Save(&channelAPI).Error; err != nil { + return err + } + if inbox.ChannelID != channelAPI.ID { + inbox.ChannelID = channelAPI.ID + } + return nil +} + +func validateChannelAPIAdditionalAttributes(value any) error { + attrs, ok := value.(map[string]any) + if !ok || attrs == nil { + return nil + } + window, ok := attrs["agent_reply_time_window"] + if !ok || window == nil || fmt.Sprint(window) == "" { + return nil + } + parsed, err := strconv.Atoi(fmt.Sprint(window)) + if err != nil || parsed <= 0 { + return errors.New("agent_reply_time_window must be greater than 0") + } + return nil +} + +func marshalChannelAPIAdditionalAttributes(value any) []byte { + if value == nil { + return []byte(`{}`) + } + data, err := json.Marshal(value) + if err != nil || !json.Valid(data) { + return []byte(`{}`) + } + return data +} + // BindChannel persists the channel id and channel_config for channel-specific // controllers that still create their dedicated channel record first. func (s *InboxService) BindChannel(ctx context.Context, accountID, id, channelID uint, channel map[string]any) (*model.Inbox, error) { @@ -630,6 +699,31 @@ func mapString(values map[string]any, key string) string { } } +func mapBool(values map[string]interface{}, key string) bool { + if values == nil { + return false + } + value, ok := values[key] + if !ok { + value, ok = values[normalizeInboxConfigKey(key)] + } + if !ok || value == nil { + return false + } + switch typed := value.(type) { + case bool: + return typed + case string: + return strings.EqualFold(typed, "true") || typed == "1" + case float64: + return typed != 0 + case int: + return typed != 0 + default: + return false + } +} + func (s *InboxService) ensureInboxWorkingHours(ctx context.Context, inbox *model.Inbox) error { if s == nil || s.repo == nil || s.repo.DB() == nil { return nil @@ -723,6 +817,7 @@ type WebWidgetConfig struct { OfflineMessageEnabled bool `json:"offline_message_enabled,omitempty"` // M11: Allow offline messages OfflineMessageTitle string `json:"offline_message_title,omitempty"` // M11: Offline form title OfflineMessageDesc string `json:"offline_message_description,omitempty"` // M11: Offline form description + HMACMandatory bool `json:"hmac_mandatory,omitempty"` } // CreateWebWidgetInboxRequest is the DTO for creating a web_widget inbox. @@ -1956,7 +2051,7 @@ func parseChannelConfigMap(configJSON string) map[string]interface{} { return config } -// ResetSecret regenerates the HMAC token for an API-type inbox channel. +// ResetSecret regenerates the webhook signing secret for an API-type inbox channel. // Reference: Chatwoot inboxes_controller#reset_secret — only works for API inboxes func (s *InboxService) ResetSecret(ctx context.Context, accountID, inboxID uint) (*model.Inbox, error) { inbox, err := s.GetByAccountAndID(ctx, accountID, inboxID) @@ -1965,26 +2060,28 @@ func (s *InboxService) ResetSecret(ctx context.Context, accountID, inboxID uint) } // Chatwoot: returns 404 for non-API inboxes - if inbox.ChannelType != string(model.InboxChannelTypeAPI) { + if inbox.ChannelType != "api" && inbox.ChannelType != string(model.InboxChannelTypeAPI) { return nil, fmt.Errorf("inbox not found: only API inboxes support reset_secret") } - // Find the ChannelAPI record and regenerate HMAC token var chAPI channelmodel.ChannelAPI if err := s.repo.DB().Where("inbox_id = ?", inboxID).First(&chAPI).Error; err != nil { return nil, fmt.Errorf("channel api not found: %w", err) } - // Generate new random HMAC token (32 bytes = 64 hex chars) - newToken, randErr := generateRandomHex(32) + newSecret, randErr := generateRandomHex(32) if randErr != nil { return nil, fmt.Errorf("failed to generate secret: %w", randErr) } - chAPI.HMACToken = newToken + chAPI.Secret = newSecret if err := s.repo.DB().Save(&chAPI).Error; err != nil { return nil, fmt.Errorf("failed to save new secret: %w", err) } + inbox.Secret = newSecret + if err := s.repo.Update(ctx, inbox); err != nil { + return nil, fmt.Errorf("failed to save inbox secret: %w", err) + } // Refresh inbox to return updated state return s.GetByAccountAndID(ctx, accountID, inboxID) diff --git a/internal/service/inbox_service_test.go b/internal/service/inbox_service_test.go index bac7bd21..2174860c 100644 --- a/internal/service/inbox_service_test.go +++ b/internal/service/inbox_service_test.go @@ -39,6 +39,7 @@ func setupInboxServiceTest(t *testing.T) (*InboxService, *gorm.DB) { &model.AgentBotInbox{}, &model.WebhookSubscription{}, &model.BackgroundJob{}, + &channelmodel.ChannelAPI{}, &channelmodel.ChannelWhatsApp{}, ), "failed to auto-migrate") @@ -199,6 +200,138 @@ func TestInboxService_CreateAllowsBelowAccountInboxLimit(t *testing.T) { assert.Equal(t, int64(2), count) } +func TestInboxService_CreateAPIInboxPersistsChannelAPI(t *testing.T) { + svc, db := setupInboxServiceTest(t) + account := &model.Account{Name: "API Channel Account", Locale: "en", Active: true, InboxLimit: 0} + require.NoError(t, db.Create(account).Error) + + inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{ + Name: "API Persist", + ChannelType: "api", + Channel: map[string]any{ + "identifier": "api-persist-inbox", + "hmac_token": "api-persist-secret", + "hmac_mandatory": true, + "webhook_url": "https://example.test/hook", + "additional_attributes": map[string]any{ + "agent_reply_time_window": 30, + }, + }, + }) + require.NoError(t, err) + + var channelAPI channelmodel.ChannelAPI + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error) + assert.Equal(t, inbox.Secret, channelAPI.Secret) + assert.Equal(t, "api-persist-inbox", channelAPI.Identifier) + assert.Equal(t, "api-persist-secret", channelAPI.HMACToken) + assert.True(t, channelAPI.HMACMandatory) + assert.Equal(t, "https://example.test/hook", channelAPI.WebhookURL) + assert.Equal(t, channelAPI.ID, inbox.ChannelID) + var attrs map[string]any + require.NoError(t, json.Unmarshal(channelAPI.AdditionalAttributes, &attrs)) + assert.Equal(t, float64(30), attrs["agent_reply_time_window"]) +} + +func TestInboxService_UpdateAPIInboxPersistsChannelAPI(t *testing.T) { + svc, db := setupInboxServiceTest(t) + account := &model.Account{Name: "API Channel Update", Locale: "en", Active: true, InboxLimit: 0} + require.NoError(t, db.Create(account).Error) + inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{ + Name: "API Update", + ChannelType: "api", + Channel: map[string]any{ + "identifier": "api-update-inbox", + "hmac_token": "api-update-secret", + "hmac_mandatory": false, + }, + }) + require.NoError(t, err) + + updated, err := svc.Update(context.Background(), account.ID, inbox.ID, UpdateInboxRequest{Channel: map[string]any{ + "hmac_mandatory": true, + "webhook_url": "https://example.test/updated-hook", + "additional_attributes": map[string]any{ + "agent_reply_time_window": 45, + }, + }}) + require.NoError(t, err) + + var channelAPI channelmodel.ChannelAPI + require.NoError(t, db.Where("inbox_id = ?", updated.ID).First(&channelAPI).Error) + assert.Equal(t, updated.Secret, channelAPI.Secret) + assert.Equal(t, "api-update-inbox", channelAPI.Identifier) + assert.Equal(t, "api-update-secret", channelAPI.HMACToken) + assert.True(t, channelAPI.HMACMandatory) + assert.Equal(t, "https://example.test/updated-hook", channelAPI.WebhookURL) + var attrs map[string]any + require.NoError(t, json.Unmarshal(channelAPI.AdditionalAttributes, &attrs)) + assert.Equal(t, float64(45), attrs["agent_reply_time_window"]) +} + +func TestInboxService_APIInboxRejectsInvalidAgentReplyTimeWindow(t *testing.T) { + svc, db := setupInboxServiceTest(t) + account := &model.Account{Name: "API Window Validation", Locale: "en", Active: true, InboxLimit: 0} + require.NoError(t, db.Create(account).Error) + + created, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{ + Name: "Invalid Window", + ChannelType: "api", + Channel: map[string]any{ + "additional_attributes": map[string]any{"agent_reply_time_window": 0}, + }, + }) + require.Error(t, err) + assert.Nil(t, created) + assert.Contains(t, err.Error(), "agent_reply_time_window must be greater than 0") + + inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{ + Name: "Valid Window", + ChannelType: "api", + Channel: map[string]any{ + "additional_attributes": map[string]any{"agent_reply_time_window": 12}, + }, + }) + require.NoError(t, err) + + updated, err := svc.Update(context.Background(), account.ID, inbox.ID, UpdateInboxRequest{Channel: map[string]any{ + "additional_attributes": map[string]any{"agent_reply_time_window": "0"}, + }}) + require.Error(t, err) + assert.Nil(t, updated) + assert.Contains(t, err.Error(), "agent_reply_time_window must be greater than 0") +} + +func TestInboxService_ResetSecretRegeneratesAPIWebhookSecret(t *testing.T) { + svc, db := setupInboxServiceTest(t) + account := &model.Account{Name: "API Secret Reset", Locale: "en", Active: true, InboxLimit: 0} + require.NoError(t, db.Create(account).Error) + + inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{ + Name: "API Secret Reset", + ChannelType: "api", + Channel: map[string]any{ + "hmac_token": "public-hmac-token", + "webhook_url": "https://example.test/reset-hook", + }, + }) + require.NoError(t, err) + + var before channelmodel.ChannelAPI + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&before).Error) + require.NotEmpty(t, before.Secret) + + reset, err := svc.ResetSecret(context.Background(), account.ID, inbox.ID) + require.NoError(t, err) + + var after channelmodel.ChannelAPI + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&after).Error) + assert.NotEqual(t, before.Secret, after.Secret) + assert.Equal(t, after.Secret, reset.Secret) + assert.Equal(t, before.HMACToken, after.HMACToken) + assert.Equal(t, "public-hmac-token", after.HMACToken) +} + // ======================================== // SetAgentBot service tests // ======================================== diff --git a/internal/service/widget_service.go b/internal/service/widget_service.go index 029783ae..bcf6d3c5 100644 --- a/internal/service/widget_service.go +++ b/internal/service/widget_service.go @@ -23,6 +23,14 @@ import ( "gorm.io/datatypes" ) +var ( + ErrWidgetConversationNotFound = errors.New("conversation not found") + ErrWidgetEndConversationDisabled = errors.New("end conversation is not permitted") + ErrWidgetMessageContentTooLong = errors.New("Content is too long (maximum is 150000 characters)") +) + +const widgetMessageContentLimit = 150000 + // TypingIndicator is the interface for broadcasting typing events. // Decoupled from the concrete ws.TypingTracker to avoid circular imports. type TypingIndicator interface { @@ -44,6 +52,7 @@ type WidgetService struct { contactInboxRepo *repository.ContactInboxRepo conversationRepo *repository.ConversationRepo messageRepo *repository.MessageRepo + transcriptMailer automation.AutomationTranscriptDeliverer typingIndicator TypingIndicator themeConfigRepo *repository.WidgetThemeConfigRepo preChatFormRepo *repository.PreChatFormRepo @@ -77,6 +86,7 @@ func NewWidgetService( contactInboxRepo: contactInboxRepo, conversationRepo: conversationRepo, messageRepo: messageRepo, + transcriptMailer: automation.NewEnvAutomationTranscriptDeliverer(), typingIndicator: typingIndicator, themeConfigRepo: themeConfigRepo, preChatFormRepo: preChatFormRepo, @@ -92,6 +102,10 @@ func (s *WidgetService) SetWorkerPool(wp *worker.WorkerPool) { s.worker = wp } +func (s *WidgetService) SetTranscriptDeliverer(deliverer automation.AutomationTranscriptDeliverer) { + s.transcriptMailer = deliverer +} + // --- DTOs --- // WidgetInitRequest is the DTO for the /widget/init endpoint. @@ -122,11 +136,14 @@ type WidgetInitResponse struct { // WidgetSendMessageRequest is the DTO for the /widget/messages endpoint. // Reference: Chatwoot WidgetMessagesController#create type WidgetSendMessageRequest struct { - WidgetToken string `json:"widget_token" validate:"required"` - Content string `json:"content" validate:"required"` - ContentType string `json:"content_type,omitempty"` // default: text - ConversationID *uint `json:"conversation_id,omitempty"` // nil → create new conversation - AttachmentIDs []string + WidgetToken string `json:"widget_token" validate:"required"` + Content string `json:"content" validate:"required"` + ContentType string `json:"content_type,omitempty"` // default: text + ConversationID *uint `json:"conversation_id,omitempty"` // nil → create new conversation + AttachmentIDs []string + CustomAttributes map[string]any + Labels []string + ReplyTo *uint } // WidgetSendMessageResponse is returned after sending a message. @@ -196,6 +213,13 @@ type PublicMessageRequest struct { Content string EchoID string SubmittedValues []map[string]any + AttachmentIDs []string +} + +type PublicMessageListOptions struct { + Before uint + Offset int + Limit int } type WidgetInboxMember struct { @@ -243,6 +267,13 @@ func (s *WidgetService) Init(ctx context.Context, req WidgetInitRequest) (*Widge if err != nil { return nil, fmt.Errorf("invalid website_token: %w", err) } + var account model.Account + if err := s.conversationRepo.DB().WithContext(ctx).Select("active", "status").First(&account, inbox.AccountID).Error; err != nil { + return nil, err + } + if !account.Active || strings.EqualFold(account.Status, "suspended") { + return nil, errors.New("Account is suspended") + } if !inbox.Enabled { return nil, errors.New("inbox is disabled") } @@ -296,6 +327,9 @@ func (s *WidgetService) SendMessage(ctx context.Context, req WidgetSendMessageRe if req.Content == "" && len(req.AttachmentIDs) == 0 { return nil, errors.New("content is required") } + if len([]rune(req.Content)) > widgetMessageContentLimit { + return nil, ErrWidgetMessageContentTooLong + } // Resolve contact by pubsub_token contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken) @@ -320,10 +354,17 @@ func (s *WidgetService) SendMessage(ctx context.Context, req WidgetSendMessageRe return nil, errors.New("conversation does not belong to this contact") } } else { - // Create new conversation - conversation, err = s.createWidgetConversation(ctx, contactInbox) - if err != nil { - return nil, fmt.Errorf("failed to create conversation: %w", err) + conversations, _, findErr := s.conversationRepo.FindByContact(ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 1) + if findErr != nil { + return nil, findErr + } + if len(conversations) > 0 { + conversation = &conversations[0] + } else { + conversation, err = s.createWidgetConversation(ctx, contactInbox, req.CustomAttributes, req.Labels) + if err != nil { + return nil, fmt.Errorf("failed to create conversation: %w", err) + } } } @@ -339,10 +380,18 @@ func (s *WidgetService) SendMessage(ctx context.Context, req WidgetSendMessageRe MessageType: "incoming", Status: "sent", } + if req.ReplyTo != nil { + if _, err := s.messageRepo.FindByConversationAndID(ctx, conversation.ID, *req.ReplyTo); err == nil { + msg.ContentAttributes = mustJSON(map[string]any{"in_reply_to": *req.ReplyTo}) + } + } if err := s.messageRepo.Create(ctx, &msg); err != nil { return nil, fmt.Errorf("failed to create message: %w", err) } + if err := s.reopenWidgetConversationForIncomingMessage(ctx, conversation); err != nil { + return nil, err + } attachments, err := s.attachWidgetUploads(ctx, &msg, req.AttachmentIDs) if err != nil { @@ -359,6 +408,20 @@ func (s *WidgetService) SendMessage(ctx context.Context, req WidgetSendMessageRe }, nil } +func (s *WidgetService) reopenWidgetConversationForIncomingMessage(ctx context.Context, conversation *model.Conversation) error { + if conversation == nil || conversation.Muted { + return nil + } + if conversation.Status != string(model.ConversationStatusSnoozed) && conversation.Status != string(model.ConversationStatusResolved) { + return nil + } + conversation.Status = string(model.ConversationStatusOpen) + conversation.SnoozedUntil = nil + return s.conversationRepo.DB().WithContext(ctx).Model(&model.Conversation{}). + Where("id = ?", conversation.ID). + Updates(map[string]any{"status": conversation.Status, "snoozed_until": nil}).Error +} + // GetConversations returns conversations for a widget contact. // Reference: Chatwoot widget SDK — fetches conversation list func (s *WidgetService) GetConversations(ctx context.Context, widgetToken string) ([]model.Conversation, error) { @@ -386,7 +449,7 @@ func (s *WidgetService) GetLatestConversation(ctx context.Context, widgetToken s return nil, err } if len(conversations) == 0 { - return nil, errors.New("conversation not found") + return nil, ErrWidgetConversationNotFound } return &conversations[0], nil } @@ -406,7 +469,7 @@ func (s *WidgetService) GetConversation(ctx context.Context, widgetToken string, return conversation, nil } -func (s *WidgetService) GetLatestConversationMessages(ctx context.Context, widgetToken string, offset, limit int) ([]model.Message, int64, *model.Conversation, error) { +func (s *WidgetService) GetLatestConversationMessages(ctx context.Context, widgetToken string, after, before uint) ([]model.Message, int64, *model.Conversation, error) { conversation, err := s.GetLatestConversation(ctx, widgetToken) if err != nil { if err.Error() == "conversation not found" { @@ -414,7 +477,7 @@ func (s *WidgetService) GetLatestConversationMessages(ctx context.Context, widge } return nil, 0, nil, err } - messages, total, err := s.messageRepo.FindByConversation(ctx, conversation.ID, offset, limit) + messages, total, err := s.messageRepo.FindByConversationFinder(ctx, conversation.ID, after, before, true) return messages, total, conversation, err } @@ -458,6 +521,13 @@ func (s *WidgetService) GetCampaignsByWebsiteToken(ctx context.Context, websiteT if err != nil { return nil, err } + var account model.Account + if err := s.conversationRepo.DB().WithContext(ctx).Select("feature_flags").First(&account, inbox.AccountID).Error; err != nil { + return nil, err + } + if !widgetAccountFeatureEnabled(account.FeatureFlags, "campaigns") { + return []WidgetCampaign{}, nil + } if s.campaignRepo == nil { return []WidgetCampaign{}, nil } @@ -485,6 +555,26 @@ func (s *WidgetService) GetCampaignsByWebsiteToken(ctx context.Context, websiteT return payload, nil } +func widgetAccountFeatureEnabled(raw, flag string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return false + } + values := map[string]bool{} + if err := json.Unmarshal([]byte(raw), &values); err == nil { + return values[flag] + } + var list []string + if err := json.Unmarshal([]byte(raw), &list); err == nil { + for _, value := range list { + if value == flag { + return true + } + } + } + return false +} + func (s *WidgetService) TrackEvent(ctx context.Context, websiteToken, widgetToken, name string, eventInfo map[string]any) error { if websiteToken == "" { return errors.New("website_token is required") @@ -506,7 +596,19 @@ func (s *WidgetService) TrackEvent(ctx context.Context, websiteToken, widgetToke if contactInbox.InboxID != inbox.ID { return errors.New("widget_token does not belong to this inbox") } - _ = eventInfo + if strings.EqualFold(name, "webwidget.triggered") { + if s.worker != nil { + if _, err := s.worker.Enqueue(ctx, "webhook:webwidget_triggered", map[string]any{ + "account_id": inbox.AccountID, + "inbox_id": inbox.ID, + "contact_inbox": contactInbox, + "event_info": eventInfo, + "website_token": websiteToken, + }); err != nil { + applogger.L().Warnf("WidgetService.TrackEvent: failed to enqueue webwidget_triggered webhook: %v", err) + } + } + } return nil } @@ -594,7 +696,7 @@ func (s *WidgetService) PublicGetInbox(ctx context.Context, inboxIdentifier stri if err != nil { return nil, false, err } - return inbox, channelAPI.HMACToken != "", nil + return inbox, channelAPI.HMACMandatory, nil } func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier string, req PublicContactRequest) (*PublicContactResponse, error) { @@ -602,7 +704,7 @@ func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier if err != nil { return nil, err } - if err := validatePublicHMAC(channelAPI.HMACToken, req.Identifier, req.IdentifierHash); err != nil { + if err := validatePublicHMAC(channelAPI.HMACToken, channelAPI.HMACMandatory, req.Identifier, req.IdentifierHash); err != nil { return nil, err } if req.SourceID == "" { @@ -658,7 +760,7 @@ func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier if err != nil { return nil, err } - if err := validatePublicHMAC(channelAPI.HMACToken, req.Identifier, req.IdentifierHash); err != nil { + if err := validatePublicHMAC(channelAPI.HMACToken, channelAPI.HMACMandatory, req.Identifier, req.IdentifierHash); err != nil { return nil, err } contact, err := s.updateContactFields(ctx, &contactInbox.Contact, WidgetContactUpdate{ @@ -698,7 +800,7 @@ func (s *WidgetService) PublicCreateConversation(ctx context.Context, inboxIdent if err != nil { return nil, err } - conversation, err := s.createWidgetConversation(ctx, contactInbox) + conversation, err := s.createWidgetConversation(ctx, contactInbox, nil, nil) if err != nil { return nil, err } @@ -712,7 +814,7 @@ func (s *WidgetService) PublicCreateConversation(ctx context.Context, inboxIdent } func (s *WidgetService) PublicGetConversation(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) { - _, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID) + _, _, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID) if err != nil { return nil, err } @@ -721,7 +823,7 @@ func (s *WidgetService) PublicGetConversation(ctx context.Context, inboxIdentifi return nil, err } for i := range conversations { - if publicConversationID(conversations[i]) == displayID && conversations[i].InboxID == contactInbox.InboxID { + if publicConversationID(conversations[i]) == displayID { return &conversations[i], nil } } @@ -771,22 +873,64 @@ func (s *WidgetService) PublicToggleTyping(ctx context.Context, inboxIdentifier, return s.typingIndicator.SetTypingOff(ctx, conversation.AccountID, conversation.ID, performer) } -func (s *WidgetService) PublicListMessages(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, offset, limit int) ([]model.Message, int64, *model.Conversation, error) { +func (s *WidgetService) PublicListMessages(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, opts PublicMessageListOptions) ([]model.Message, int64, *model.Conversation, error) { conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID) if err != nil { return nil, 0, nil, err } - messages, total, err := s.messageRepo.FindByConversation(ctx, conversation.ID, offset, limit) + messages, total, err := s.publicConversationMessages(ctx, conversation.ID, opts) return messages, total, conversation, err } -func (s *WidgetService) PublicCreateMessage(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, req PublicMessageRequest) (*model.Message, *model.Conversation, error) { - if strings.TrimSpace(req.Content) == "" { - return nil, nil, errors.New("content is required") +func (s *WidgetService) publicConversationMessages(ctx context.Context, conversationID uint, opts PublicMessageListOptions) ([]model.Message, int64, error) { + query := s.messageRepo.DB().WithContext(ctx).Model(&model.Message{}). + Where("conversation_id = ?", conversationID). + Where("NOT (private = ? OR message_type = ?)", true, model.MessageTypeActivity) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var messages []model.Message + if opts.Before != 0 { + err := query.Where("id < ?", opts.Before).Order("created_at DESC, id DESC").Limit(20).Find(&messages).Error + if err != nil { + return nil, 0, err + } + reverseMessages(messages) + return messages, total, nil + } + limit := opts.Limit + if limit <= 0 { + limit = 20 + } + offset := opts.Offset + if offset < 0 { + offset = 0 + } + err := query.Offset(offset).Limit(limit).Order("created_at DESC, id DESC").Find(&messages).Error + if err != nil { + return nil, 0, err + } + reverseMessages(messages) + return messages, total, err +} + +func reverseMessages(messages []model.Message) { + for left, right := 0, len(messages)-1; left < right; left, right = left+1, right-1 { + messages[left], messages[right] = messages[right], messages[left] + } +} + +func (s *WidgetService) PublicCreateMessage(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, req PublicMessageRequest) (*model.Message, *model.Conversation, []model.Attachment, error) { + if strings.TrimSpace(req.Content) == "" && len(req.AttachmentIDs) == 0 { + return nil, nil, nil, errors.New("content is required") + } + if len([]rune(req.Content)) > widgetMessageContentLimit { + return nil, nil, nil, ErrWidgetMessageContentTooLong } conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID) if err != nil { - return nil, nil, err + return nil, nil, nil, err } message := &model.Message{ ConversationID: conversation.ID, @@ -801,9 +945,13 @@ func (s *WidgetService) PublicCreateMessage(ctx context.Context, inboxIdentifier SourceID: req.EchoID, } if err := s.messageRepo.Create(ctx, message); err != nil { - return nil, nil, err + return nil, nil, nil, err } - return message, conversation, nil + attachments, err := s.attachWidgetUploads(ctx, message, req.AttachmentIDs) + if err != nil { + return nil, nil, nil, err + } + return message, conversation, attachments, nil } func (s *WidgetService) PublicUpdateMessage(ctx context.Context, inboxIdentifier, sourceID string, displayID, messageID uint, req PublicMessageRequest) (*model.Message, *model.Conversation, error) { @@ -917,10 +1065,6 @@ func (s *WidgetService) SetUser(ctx context.Context, req WidgetSetUserRequest) ( if req.WidgetToken == "" { return nil, errors.New("widget_token required") } - if req.Identifier == "" { - return nil, errors.New("identifier is required") - } - inbox, err := s.GetInboxByWebsiteToken(ctx, req.WebsiteToken) if err != nil { return nil, err @@ -929,7 +1073,7 @@ func (s *WidgetService) SetUser(ctx context.Context, req WidgetSetUserRequest) ( if err != nil { return nil, fmt.Errorf("invalid widget config: %w", err) } - if req.IdentifierHash != "" && !VerifyHMAC(widgetConfig.HMACToken, req.Identifier, req.IdentifierHash) { + if shouldVerifyWidgetSetUserHMAC(widgetConfig, req) && !VerifyHMAC(widgetConfig.HMACToken, req.Identifier, req.IdentifierHash) { return nil, errors.New("HMAC failed: Invalid Identifier Hash Provided") } @@ -967,7 +1111,7 @@ func (s *WidgetService) SetUser(ctx context.Context, req WidgetSetUserRequest) ( if err != nil { return nil, err } - if req.IdentifierHash != "" && !currentContactInbox.HMACVerified { + if shouldVerifyWidgetSetUserHMAC(widgetConfig, req) && !currentContactInbox.HMACVerified { currentContactInbox.HMACVerified = true if err := s.contactInboxRepo.Update(ctx, currentContactInbox); err != nil { return nil, err @@ -976,6 +1120,19 @@ func (s *WidgetService) SetUser(ctx context.Context, req WidgetSetUserRequest) ( return &WidgetSetUserResponse{Contact: contact, WidgetAuthToken: widgetAuthToken}, nil } +func shouldVerifyWidgetSetUserHMAC(widgetConfig *WebWidgetConfig, req WidgetSetUserRequest) bool { + if widgetConfig == nil { + return req.IdentifierHash != "" + } + if req.IdentifierHash == "" && !widgetConfig.HMACMandatory { + return false + } + if len(req.CustomAttributes) > 0 && req.Identifier == "" { + return false + } + return true +} + func (s *WidgetService) UpdateMessage(ctx context.Context, req WidgetMessageUpdate) (*model.Contact, *model.Message, error) { if req.WidgetToken == "" { return nil, nil, errors.New("widget_token required") @@ -997,7 +1154,12 @@ func (s *WidgetService) UpdateMessage(ctx context.Context, req WidgetMessageUpda } contact := &contactInbox.Contact - if strings.TrimSpace(req.ContactEmail) != "" || strings.TrimSpace(req.ContactName) != "" { + if strings.TrimSpace(req.ContactEmail) != "" && message.ContentType == string(model.MessageContentTypeInputEmail) { + contact, err = s.identifyWidgetInputEmailContact(ctx, contact, contactInbox, conversation, req) + if err != nil { + return nil, nil, err + } + } else if strings.TrimSpace(req.ContactEmail) != "" || strings.TrimSpace(req.ContactName) != "" { contact, err = s.updateContactFields(ctx, contact, WidgetContactUpdate{ Name: strings.TrimSpace(req.ContactName), Email: strings.ToLower(strings.TrimSpace(req.ContactEmail)), @@ -1020,6 +1182,32 @@ func (s *WidgetService) UpdateMessage(ctx context.Context, req WidgetMessageUpda return contact, message, nil } +func (s *WidgetService) identifyWidgetInputEmailContact(ctx context.Context, contact *model.Contact, contactInbox *model.ContactInbox, conversation *model.Conversation, req WidgetMessageUpdate) (*model.Contact, error) { + email := strings.ToLower(strings.TrimSpace(req.ContactEmail)) + name := strings.TrimSpace(req.ContactName) + if name == "" { + name = strings.Split(email, "@")[0] + } + existing, err := s.contactRepo.FindByEmail(ctx, conversation.AccountID, email) + if err == nil && existing.ID != contact.ID { + db := s.conversationRepo.DB().WithContext(ctx) + if err := db.Model(&model.Conversation{}).Where("id = ?", conversation.ID).Update("contact_id", existing.ID).Error; err != nil { + return nil, err + } + if err := db.Model(&model.ContactInbox{}).Where("id = ?", contactInbox.ID).Update("contact_id", existing.ID).Error; err != nil { + return nil, err + } + if err := db.Model(&model.Message{}).Where("sender_id = ? AND sender_type IN ?", contact.ID, []string{"Contact", "contact"}).Update("sender_id", existing.ID).Error; err != nil { + return nil, err + } + if err := db.Delete(&model.Contact{}, contact.ID).Error; err != nil { + return nil, err + } + return existing, nil + } + return s.updateContactFields(ctx, contact, WidgetContactUpdate{Name: name, Email: email}) +} + func (s *WidgetService) SendTranscript(ctx context.Context, widgetToken string) error { conversation, err := s.GetLatestConversation(ctx, widgetToken) if err != nil { @@ -1032,17 +1220,84 @@ func (s *WidgetService) SendTranscript(ctx context.Context, widgetToken string) if contact.Email == "" { return nil } + var account model.Account + if err := s.conversationRepo.DB().WithContext(ctx).First(&account, conversation.AccountID).Error; err != nil { + return err + } + now := time.Now() + if !account.EmailTranscriptEnabled() { + return ErrEmailTranscriptDisabled + } + if limit := account.EmailRateLimit(); limit > 0 && account.EmailsSentToday(now) >= limit { + return ErrEmailRateLimited + } + subject, body, err := s.buildWidgetTranscriptEmail(ctx, conversation) + if err != nil { + return err + } + if s.transcriptMailer != nil { + _, err = s.transcriptMailer.DeliverTranscript(ctx, automation.AutomationTranscriptRequest{ + AccountID: conversation.AccountID, + ConversationID: conversation.ID, + Recipient: contact.Email, + Subject: subject, + Body: body, + }) + if err != nil { + return err + } + } + if err := account.IncrementEmailSentCount(now); err != nil { + return err + } + if err := s.conversationRepo.DB().WithContext(ctx).Model(&model.Account{}).Where("id = ?", account.ID).Update("custom_attributes", account.CustomAttributes).Error; err != nil { + return err + } return nil } -func (s *WidgetService) AddDyteParticipant(ctx context.Context, websiteToken string, messageID uint) (map[string]any, error) { +func (s *WidgetService) buildWidgetTranscriptEmail(ctx context.Context, conversation *model.Conversation) (string, string, error) { + var messages []model.Message + if err := s.messageRepo.DB().WithContext(ctx). + Where("conversation_id = ? AND account_id = ? AND private = ? AND message_type IN ?", conversation.ID, conversation.AccountID, false, []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)}). + 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 (s *WidgetService) AddDyteParticipant(ctx context.Context, websiteToken, widgetToken string, messageID uint) (map[string]any, error) { if websiteToken == "" { return nil, errors.New("website_token is required") } + if widgetToken == "" { + return nil, errors.New("widget_token required") + } inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken) if err != nil { return nil, err } + contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) + if err != nil { + return nil, fmt.Errorf("invalid widget_token: %w", err) + } + if contactInbox.InboxID != inbox.ID { + return nil, errors.New("widget_token does not belong to this inbox") + } message, err := s.messageRepo.FindByID(ctx, messageID) if err != nil { return nil, err @@ -1050,8 +1305,15 @@ func (s *WidgetService) AddDyteParticipant(ctx context.Context, websiteToken str if message.InboxID != inbox.ID { return nil, errors.New("message does not belong to this inbox") } + conversation, err := s.conversationRepo.FindByID(ctx, message.ConversationID) + if err != nil { + return nil, err + } + if conversation.ContactID != contactInbox.ContactID { + return nil, errors.New("message does not belong to this contact") + } if message.ContentType != "integrations" { - return nil, errors.New("invalid message type") + return nil, errors.New("Invalid message type. Action not permitted") } attrs := jsonMap(message.ContentAttributes) data, _ := attrs["data"].(map[string]any) @@ -1129,13 +1391,68 @@ func (s *WidgetService) ResolveLatestConversation(ctx context.Context, widgetTok if err != nil { return nil, err } + allowed, err := s.latestConversationAllowsEnd(ctx, conversation) + if err != nil { + return nil, err + } + if !allowed { + return nil, ErrWidgetEndConversationDisabled + } + if conversation.Status == string(model.ConversationStatusResolved) { + return conversation, nil + } + contactName := "visitor" + contact, contactErr := s.contactRepo.FindByID(ctx, conversation.ContactID) + if contactErr == nil && strings.TrimSpace(contact.Name) != "" { + contactName = strings.TrimSpace(contact.Name) + } conversation.Status = string(model.ConversationStatusResolved) if err := s.conversationRepo.Update(ctx, conversation); err != nil { return nil, err } + if err := s.messageRepo.Create(ctx, &model.Message{ + ConversationID: conversation.ID, + AccountID: conversation.AccountID, + InboxID: conversation.InboxID, + SenderID: &conversation.ContactID, + SenderType: string(model.SenderTypeContact), + Content: "Conversation was resolved by " + contactName, + ContentType: string(model.MessageContentTypeText), + MessageType: string(model.MessageTypeActivity), + Status: string(model.MessageStatusSent), + }); err != nil { + return nil, err + } return conversation, nil } +func (s *WidgetService) latestConversationAllowsEnd(ctx context.Context, conversation *model.Conversation) (bool, error) { + inbox, err := s.inboxRepo.FindByID(ctx, conversation.InboxID) + if err != nil { + return false, err + } + config := parseChannelConfigMap(inbox.ChannelConfig) + flags, ok := config["selected_feature_flags"] + if !ok { + return false, nil + } + switch values := flags.(type) { + case []interface{}: + for _, value := range values { + if text, ok := value.(string); ok && text == "end_conversation" { + return true, nil + } + } + case []string: + for _, value := range values { + if value == "end_conversation" { + return true, nil + } + } + } + return false, nil +} + func (s *WidgetService) SetLatestConversationCustomAttributes(ctx context.Context, widgetToken string, attrs map[string]any) (*model.Conversation, error) { conversation, err := s.GetLatestConversation(ctx, widgetToken) if err != nil { @@ -1304,20 +1621,22 @@ func (s *WidgetService) findOrCreateContactInbox(ctx context.Context, contactID, } // createWidgetConversation creates a new conversation for a widget contact. -func (s *WidgetService) createWidgetConversation(ctx context.Context, contactInbox *model.ContactInbox) (*model.Conversation, error) { +func (s *WidgetService) createWidgetConversation(ctx context.Context, contactInbox *model.ContactInbox, customAttributes map[string]any, labels []string) (*model.Conversation, error) { inbox, err := s.inboxRepo.FindByID(ctx, contactInbox.InboxID) if err != nil { return nil, err } conversation := model.Conversation{ - AccountID: inbox.AccountID, - InboxID: inbox.ID, - ContactID: contactInbox.ContactID, - ContactInboxID: &contactInbox.ID, - Status: "open", - ChannelType: inbox.ChannelType, - Channel: inbox.ChannelType, + AccountID: inbox.AccountID, + InboxID: inbox.ID, + ContactID: contactInbox.ContactID, + ContactInboxID: &contactInbox.ID, + Status: "open", + ChannelType: inbox.ChannelType, + Channel: inbox.ChannelType, + CustomAttributes: mustJSON(customAttributes), + Labels: strings.Join(s.validWidgetLabels(ctx, inbox.AccountID, labels), ","), } if err := s.conversationRepo.Create(ctx, &conversation); err != nil { @@ -1326,6 +1645,31 @@ func (s *WidgetService) createWidgetConversation(ctx context.Context, contactInb return &conversation, nil } +func (s *WidgetService) validWidgetLabels(ctx context.Context, accountID uint, labels []string) []string { + if len(labels) == 0 { + return nil + } + seen := map[string]struct{}{} + valid := make([]string, 0, len(labels)) + for _, label := range labels { + label = strings.TrimSpace(label) + if label == "" { + continue + } + if _, ok := seen[label]; ok { + continue + } + if s.tagRepo != nil { + if _, err := s.tagRepo.FindByNameAndAccountID(ctx, accountID, label); err != nil { + continue + } + } + seen[label] = struct{}{} + valid = append(valid, label) + } + return valid +} + // VerifyHMAC validates the HMAC signature from the widget client. // Reference: Chatwoot web_widget HMAC verification — ensures the client // hasn't tampered with the identifier (used for authenticated contacts). @@ -1647,8 +1991,11 @@ func (s *WidgetService) resolvePublicContactInbox(ctx context.Context, inboxIden return channelAPI, contactInbox, nil } -func validatePublicHMAC(hmacToken, identifier, signature string) error { +func validatePublicHMAC(hmacToken string, mandatory bool, identifier, signature string) error { if signature == "" { + if mandatory { + return errors.New("HMAC failed: Invalid Identifier Hash Provided") + } return nil } if !VerifyHMAC(hmacToken, identifier, signature) { diff --git a/internal/service/widget_service_test.go b/internal/service/widget_service_test.go index e8dd7dce..95173bd3 100644 --- a/internal/service/widget_service_test.go +++ b/internal/service/widget_service_test.go @@ -411,21 +411,23 @@ func TestWidgetService_GetConversations(t *testing.T) { }) require.NoError(t, err) - // Send 2 messages → creates 2 conversations - _, err = svc.SendMessage(ctx, WidgetSendMessageRequest{ + // Sending without a conversation_id reuses the latest widget conversation, + // matching Chatwoot widget message behavior. + firstResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{ WidgetToken: initResp.WidgetToken, Content: "Msg 1", }) require.NoError(t, err) - _, err = svc.SendMessage(ctx, WidgetSendMessageRequest{ + secondResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{ WidgetToken: initResp.WidgetToken, Content: "Msg 2", }) require.NoError(t, err) + assert.Equal(t, firstResp.ConversationID, secondResp.ConversationID) convs, err := svc.GetConversations(ctx, initResp.WidgetToken) require.NoError(t, err) - assert.Len(t, convs, 2) + assert.Len(t, convs, 1) } func TestWidgetService_GetConversations_InvalidToken(t *testing.T) { @@ -623,6 +625,57 @@ func TestWidgetService_UpdateContact_InvalidToken(t *testing.T) { assert.Error(t, err) } +func TestWidgetService_SetUserRequiresHMACWhenMandatory(t *testing.T) { + db, svc := setupWidgetServiceTest(t) + ctx := context.Background() + + _, inbox := seedWidgetInbox(t, db) + config, err := ParseWebWidgetConfig(inbox.ChannelConfig) + require.NoError(t, err) + config.HMACMandatory = true + configJSON, err := json.Marshal(config) + require.NoError(t, err) + require.NoError(t, db.Model(inbox).Update("channel_config", string(configJSON)).Error) + + initResp, err := svc.Init(ctx, WidgetInitRequest{WebsiteToken: "test_ws_token_123"}) + require.NoError(t, err) + + resp, err := svc.SetUser(ctx, WidgetSetUserRequest{ + WebsiteToken: "test_ws_token_123", + WidgetToken: initResp.WidgetToken, + Identifier: "external-123", + }) + assert.Nil(t, resp) + require.Error(t, err) + assert.Contains(t, err.Error(), "HMAC failed") +} + +func TestWidgetService_SetUserCustomAttributesWithoutIdentifierSkipsHMAC(t *testing.T) { + db, svc := setupWidgetServiceTest(t) + ctx := context.Background() + + _, inbox := seedWidgetInbox(t, db) + config, err := ParseWebWidgetConfig(inbox.ChannelConfig) + require.NoError(t, err) + config.HMACMandatory = true + configJSON, err := json.Marshal(config) + require.NoError(t, err) + require.NoError(t, db.Model(inbox).Update("channel_config", string(configJSON)).Error) + + initResp, err := svc.Init(ctx, WidgetInitRequest{WebsiteToken: "test_ws_token_123"}) + require.NoError(t, err) + + resp, err := svc.SetUser(ctx, WidgetSetUserRequest{ + WebsiteToken: "test_ws_token_123", + WidgetToken: initResp.WidgetToken, + CustomAttributes: map[string]any{"order_id": "12345"}, + }) + require.NoError(t, err) + require.NotNil(t, resp) + attrs := jsonMap(resp.Contact.CustomAttributes) + assert.Equal(t, "12345", attrs["order_id"]) +} + // ========== ToggleTyping Tests ========== func TestWidgetService_ToggleTyping_On(t *testing.T) { diff --git a/internal/webhookutil/timeout.go b/internal/webhookutil/timeout.go new file mode 100644 index 00000000..164c38bd --- /dev/null +++ b/internal/webhookutil/timeout.go @@ -0,0 +1,31 @@ +package webhookutil + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/gochat/gochat/internal/model" + "gorm.io/gorm" +) + +const ( + TimeoutConfigName = "WEBHOOK_TIMEOUT" + DefaultTimeout = 5 * time.Second +) + +func Timeout(ctx context.Context, db *gorm.DB) time.Duration { + if db == nil { + return DefaultTimeout + } + var cfg model.InstallationConfig + if err := db.WithContext(ctx).Where("name = ?", TimeoutConfigName).First(&cfg).Error; err != nil { + return DefaultTimeout + } + seconds, err := strconv.Atoi(strings.TrimSpace(cfg.Value)) + if err != nil || seconds <= 0 { + return DefaultTimeout + } + return time.Duration(seconds) * time.Second +}