a47eb375ad0bbb94a38c6f1b60730bfb0d04a3ab
1946 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a47eb375ad |
feat(captain): add advanced inactivity policy backend (4/5) (#15306)
Captain can now use each assistant's saved setting when a customer stops replying. Captain can review the conversation and resolve or hand it off, resolve it after the selected time without review, or leave it pending until the customer replies. The job checks the conversation again while holding a database lock before it changes the status. A new customer reply or another worker cannot cause an outdated resolve or handoff. ## Closes [AI-163](https://linear.app/chatwoot/issue/AI-163) ## What changed - Added assistant modes for review, always resolve, and wait for the customer. - Kept the account setting as the fallback for assistants that do not have a saved mode. - Skipped scheduling when resolution is disabled on the assistant or through the older account setting. - Rechecked the conversation status and activity time before each resolve or handoff. - Recorded events only after a status change succeeds. - Kept out of office messages out of campaign conversations. ## How to test 1. Set an assistant to review conversations. Run the inactivity job with complete and incomplete decisions. Confirm the first conversation is resolved and the second is handed off. 2. Set the assistant to always resolve. Confirm an eligible pending conversation is resolved after the selected time. 3. Set the assistant to wait for the customer. Confirm the scheduler does not enqueue the inactivity job and the conversation remains pending. 4. Add a customer reply while the review is running. Confirm the job does not resolve or hand off the updated conversation. 5. Run two workers for the same conversation. Confirm only one status change and one event are recorded. --------- Co-authored-by: iamsivin <iamsivin@gmail.com> |
||
|
|
121b743f88 |
fix: scope Captain assistant responses to account (#15386)
## Description Fixes account scoping for Captain assistant responses. Create and update accepted an `assistant_id` from the request. The model then set the response account from that assistant. The controller lookup read the top level parameter, while the API sends the ID inside `assistant_response`, and create did not use the lookup result. The controller now resolves the nested assistant ID through `Current.account`, removes `assistant_id` before assigning request fields, and assigns the scoped assistant directly. The model now fills the account only when it is blank and rejects a response when its account and assistant do not match. Linear issue: [CW-7913](https://linear.app/chatwoot/issue/CW-7913/ghsa-phpm-m2mf-r8r9-captain-assistant-responses-writes-into-another) ## Type of change - [x] Bug fix ## How has this been tested? - Ran `bundle exec rspec spec/enterprise/controllers/api/v1/accounts/captain/assistant_responses_controller_spec.rb spec/enterprise/models/captain/assistant_response_spec.rb`. All 19 examples passed. - Ran the two new account isolation examples against the original code. Both failed and reproduced the create and update issue. Both pass with this fix. - Ran RuboCop on the five changed Ruby files. It found no offenses. ## Checklist - [x] My code follows the style guidelines of this project - [x] I have performed a self review of my code - [x] I have added tests that prove the fix is effective - [x] New and existing unit tests pass locally with my changes |
||
|
|
1e17cbe0e7 |
feat(voice): transcribe Twilio call recordings (#15241)
Twilio voice calls now get an AI transcript alongside the recording. Once a call ends and its recording is stored, we transcribe it and show the text under the audio player in the call bubble — the same experience WhatsApp voice notes already have. Transcription runs on Captain and consumes Captain response credits, so it only kicks in for accounts with Captain enabled and audio transcriptions turned on. ## How to test 1. On an account with Captain enabled and Settings → Account → Audio transcriptions on, make a call on a Twilio voice inbox and hang up. 2. Open the conversation. The voice call bubble shows the recording player once Twilio delivers the recording. 3. Shortly after, the transcript appears under the player — no refresh needed. 4. Turn audio transcriptions off (or exhaust Captain credits) and repeat: the recording still appears, the transcript does not. ## What changed - `Llm::SpeechToTextService` (new) — blob-in/text-out transcription engine extracted from `Messages::AudioTranscriptionService`: size limit, temp-file download, model resolution via `Llm::FeatureRouter`, the OpenAI call, and Captain credit accounting. `.available_for?` holds the shared gate. - `Messages::AudioTranscriptionService` — now a thin wrapper over that engine; its public contract is unchanged, so `Captain::OpenAiMessageBuilderService` is unaffected. - `Voice::CallTranscriptionService` / `Voice::CallTranscriptionJob` (new) — transcribe `call.recording` into `calls.transcript`, then rebroadcast the message so clients pick it up over the wire. - `Voice::Provider::Twilio::RecordingAttachmentService` — enqueues the job after the recording is attached. The API and frontend needed no changes: `calls.transcript` already existed, `_call.json.jbuilder` already serialized it, and `VoiceCall.vue` already fed it to the audio chip. Nothing had ever written the column. Also wires `instrument_audio_transcription`, which existed but was never called, so both transcription paths now emit LLM spans. |
||
|
|
a24f5a3e7a |
fix: anchor contact phone number validation (#15415)
Contact phone numbers with stray text in front of them, like
`abc+12312312321`, were saving successfully instead of being rejected as
invalid. Agents could end up with unusable numbers on a contact, and the
same values were persisted rather than discarded when captured through
the live chat widget.
## How to reproduce
1. Open a contact and edit its details.
2. Set the phone number to `abc+12312312321` via the API (`PATCH
/api/v1/accounts/:id/contacts/:id`).
3. Before this change the update succeeds. Now it fails validation.
## What changed
The E.164 format check was missing a leading `\A` anchor, so Rails
matched it anywhere in the string and accepted any prefix ahead of a
valid number. Both the validation and the `phone_number_format` fallback
used by `discard_invalid_attrs` are now anchored, so the widget path
discards these values instead of storing them.
Contacts already holding a prefixed number will now fail validation on
their next save. Worth a count on production first:
```sql
SELECT count(*) FROM contacts WHERE phone_number !~ '^\+[1-9][0-9]{1,14}$' AND phone_number <> '';
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||
|
|
8397fa7eaf |
fix: include account_id in FCM push payload (#15260)
## Description - fcm_push_data didn't include the account id, so mobile clients had no way to know which account a push notification belonged to. This adds account_id to the FCM payload (it was already present in push_event_data for ActionCable, just not FCM). - Prerequisite for the mobile-side fix for [chatwoot-mobile-app#1121](https://github.com/chatwoot/chatwoot-mobile-app/pull/1121) (opening a conversation from a notification for a non-active account). - Added a spec asserting fcm_push_data includes the account id. Fixes [CW-4235](https://linear.app/chatwoot/issue/CW-4235/the-conversation-fails-to-open-when-the-notifications-account-differs) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> |
||
|
|
8842c71a90 |
fix: Handle NUL bytes in canned response search (#15397)
Canned response search now removes NUL bytes from user-provided search text before passing it to PostgreSQL, so malformed input returns normal search results instead of a database encoding error. ## Closes - [CW-7922](https://linear.app/chatwoot/issue/CW-7922/harden-backend-paths-causing-production-sentry-errors) - [Sentry 7663466064](https://chatwoot-p3.sentry.io/issues/7663466064/) ## How to reproduce Call the canned responses endpoint with a search parameter containing a NUL byte. PostgreSQL previously raised `PG::UntranslatableCharacter` while evaluating the search query. ## What changed - Strip NUL bytes once at the controller boundary. - Reuse the sanitized value for matching and result ranking. - Add request coverage for a search term containing a NUL byte. |
||
|
|
eaecc43ca4 |
fix: search returning 500 when a conversation has no messages (#15328)
## Description
`SearchService#filter_conversations` matches conversations on the
display id and on the contact name, email, phone number and identifier.
It never looks at message content, so a conversation with no messages is
a valid result whenever its contact matches.
The search views did not account for that. They rendered
`conversation.messages.try(:first)`, which is `nil` for such a
conversation, and `api/v1/models/_message` calls `message.id` on it:
```
ActionView::Template::Error (undefined method 'id' for nil):
1: json.id message.id
app/views/api/v1/models/_message.json.jbuilder:1
app/views/api/v1/accounts/search/_message.json.jbuilder:1
app/views/api/v1/accounts/search/conversations.json.jbuilder:8
```
A single conversation without messages is enough to turn the whole
search request into a 500 for that query, so the agent loses
conversation search entirely until that conversation gets a message.
Both views that render a conversation search result were affected, so
this applies to `GET /search/conversations` and to the combined `GET
/search`.
The fix guards the message partial the same way the neighbouring
`contact`, `inbox` and `agent` partials in those same views are already
guarded. When there is no message the key is rendered as an empty
object, which is what already happens for a missing contact, inbox or
assignee.
**How to reproduce**
1. Create a conversation without any message (for example via `POST
/api/v1/accounts/{id}/conversations` without a `message`).
2. Search for the contact's name or phone number: `GET
/api/v1/accounts/{id}/search/conversations?q=<phone>`.
3. The request returns 500.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Added one spec per affected endpoint in
`spec/controllers/api/v1/accounts/search_controller_spec.rb`, each
creating a conversation with no messages whose contact matches the query
and asserting that it is returned. Both fail with a 500 before the
change.
Also reproduced manually on a running instance: searching a contact that
had a conversation with no messages returned 500, and returns 200 with
`"message": {}` after the change.
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] Any dependent changes have been merged and published in downstream
modules
---
Related: #15289 documents the atomic `POST /conversations` with an
inline `message`, which avoids creating conversations without messages
in the first place. This fix is independent of it — it protects the
search regardless of how the conversation ended up without messages
(created by an agent before replying, by an API integration, by campaign
tooling, or by a flow that did not complete).
Co-authored-by: Sojan Jose <sojan@pepalo.com>
|
||
|
|
0a2293f921 |
feat: record Captain conversation outcome episodes from lifecycle events [CW-7792] (#15316)
Records Captain conversation outcomes at episode grain so reporting can distinguish initial demand from reopened conversations and measure replies, handoffs, resolutions, human follow-up, and CSAT. Eligibility creates the episode at demand time. Message-derived fields are snapshotted from persisted messages at handoff or resolution, keeping terminal analytics accurate without writing outcomes for every message. Outcome tracking remains reporting-only and fail-open. Builds on the episode-grain schema from #15315. ## Closes - https://linear.app/chatwoot/issue/CW-7792 ## How to test 1. Enable `captain_integration_v2` and connect a Captain assistant to an inbox. 2. Send an inbound customer message and confirm an initial outcome episode is created at the message timestamp. 3. Let Captain reply and then resolve or hand off the conversation. Confirm the episode records Captain reply counts and timestamps, the outcome timestamp, and the handoff category where applicable. 4. Reply after resolution and confirm a `reopen` episode is created while preserving the previous episode. 5. Resolve the reopened conversation and submit CSAT. Confirm the response is attributed to the episode that issued the survey. ## What changed - Creates the initial episode from demand-level eligibility and appends a new episode when a resolved conversation reopens. - Snapshots Captain replies and the first qualifying human reply from persisted messages at handoff and resolution. - Attributes asynchronous resolution events using the episode active at the event timestamp. - Records later CSAT responses using the survey message timestamp. - Keeps boundary writes transactional and fail-open without retries, advisory locks, late-boundary repair, or handoff self-healing. - Adds schema-constrained handoff reason categories, including lifecycle coverage for incomplete V2 tool fallback handoffs. Open, non-terminal episodes may retain empty or stale message-derived fields until handoff or resolution. |
||
|
|
1693525124 |
fix(captain): scope copilot conversation access (#15249)
## Description Captain Copilot's `get_conversation` tool returned any conversation in the account, without checking whether the agent asking for it could open that conversation from the inbox view. An agent who belongs to a single inbox, or who holds a narrow custom role, could therefore read the message history of conversations outside their access, including the private notes on them. The tool now runs the same permission filter that the conversation list endpoint and Copilot's own `search_conversation` tool already use, so it returns only the conversations the agent can already open. Administrators see no change, because the filter returns the whole account scope for them. The Copilot chat service had the same gap. When an agent opened Copilot while viewing a conversation, the service looked that conversation up by account alone and wrote its ID and contact ID into the system prompt. It now resolves the conversation through the same filter, and leaves the context out when the agent cannot access it. ## Closes https://linear.app/chatwoot/issue/CW-7768 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How to reproduce 1. Create two inboxes in one account, for example Inbox A and Inbox B. 2. Add an agent to Inbox A only, then start a conversation in Inbox B and leave a private note on it. 3. Sign in as that agent, open Copilot, and ask it for the Inbox B conversation by its ID. 4. Before the change Copilot returns the full message history including the private note. After the change it reports that the conversation was not found. Opening the same conversation from the inbox view as that agent is rejected both before and after the change, so the inbox view and Copilot now agree on what the agent can read. ## Checklist - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules |
||
|
|
bdbbaa38de |
feat(captain): add inactivity timer backend (2/5) (#15303)
Captain V2 assistants can now persist a configurable inactivity timer and choose whether inactivity resolution sends the saved closing message or resolves silently. This PR contains only the API, persistence, runtime behavior, and backend specs. ## Closes [AI-163](https://linear.app/chatwoot/issue/AI-163) ## Depends on Stack 2 of 5. Based on the assistant-policy foundation in #15299. The frontend follows in #15308. ## What changed - Added per-assistant inactivity duration and resolution-message settings with safe defaults. - Restricted the Part 2 settings API to Captain V2 while keeping the Part 1 policy mode available without V2. - Updated inactivity handling to use the assistant timer and skip the public resolution message when disabled. - Serialized the effective timer and message settings for the frontend. - Added model, request, and job coverage, including the explicit Captain V2 boundary. ## How to test 1. Enable Captain V2 and update `auto_resolve_after` and `send_inactivity_resolution_message` through the assistant API. 2. Run the inactivity job and confirm it uses the assistant timer. 3. Disable the resolution message and confirm the conversation resolves silently. 4. Disable Captain V2 and confirm timer/message updates are ignored while `auto_resolve_mode` remains updateable. --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> |
||
|
|
81fc35e9e6 |
chore: upgrade Rails to 7.2.3.1 (#13437)
This upgrades Chatwoot to Rails 7.2.3.1 while retaining the current Rails 7.0 framework defaults, so the runtime upgrade can be deployed and observed independently from default-behavior changes. ## What changed - Upgrade Rails and the compatible dependency set to Rails 7.2.3.1. - Keep `config.load_defaults 7.0` for a staged, lower-risk rollout. - Replace the unmaintained Azure Active Storage fork with the maintained `azure-blob` adapter while preserving the `microsoft` service name. - Pin Sidekiq 7.3.10 with `connection_pool` 2.x after validating scheduled-job execution against Redis. - Update Rails 7.2 compatibility surfaces in Active Record, strong parameters, migrations, storage, and tests. - Add read-only production preflight checks, an opt-in Active Storage smoke script, a deployment runbook, and the full Rails 7.2/8.0/8.1 assessment. ## How to test 1. Sign in and verify the dashboard and conversation UI load normally. 2. Open the agent-management modal and confirm agent data is rendered. 3. Create an API inbox and wait for the asynchronous deletion flow to complete. 4. Open Super Admin pages, including instance status and account-user management. 5. Upload and download an attachment using the configured Active Storage service. 6. Confirm recurring Sidekiq Cron jobs register and execute after startup. ## Rollout Follow `docs/rails_upgrades/7_2.md` for pre-deploy checks, deployment order, smoke tests, monitoring, and rollback. Run `bundle exec rails runner script/rails_upgrade/preflight.rb` against a production-equivalent environment before rollout. ## Tracking - [CW-5863 — Upgrade Rails to 8+](https://linear.app/chatwoot/issue/CW-5863/upgrade-rails-to-8) - [Rails 7.2 to 8.1 upgrade and production rollout plan](https://linear.app/chatwoot/document/chatwoot-rails-72-to-81-upgrade-and-production-rollout-plan-44e9f4964cb2) --------- Co-authored-by: Shivam Mishra <scm.mymail@gmail.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> |
||
|
|
f12529105b |
fix: align AgentBot ownership with conversation counts (#15343)
AgentBot-owned conversations are now treated as assigned across conversation lists, counts, pagination, permissions, unread membership, human auto-assignment, and advanced assignee filters. ## Closes - https://linear.app/chatwoot/issue/CW-7689/align-agent-bot-ownership-with-unassigned-counts-and-pagination ## Follow-ups - https://linear.app/chatwoot/issue/CW-7870/refresh-agentbot-ownership-state-when-deleting-an-agent-bot tracks ownership refresh during AgentBot deletion. - https://linear.app/chatwoot/issue/CW-7899/refresh-saved-filter-totals-after-live-conversation-ownership-changes tracks the existing saved-filter header count refresh gap. ## Why The backend treated every conversation without a human assignee as unassigned, even when an AgentBot owned it. The frontend already hid AgentBot-owned conversations from the Unassigned list, so counts, pagination, filters, unread membership, direct-access permissions, and auto-assignment could disagree with the visible queue. ## What changed - Treat conversations with either a human assignee or AgentBot owner as assigned. - Keep conversation counts, pagination, unread memberships, advanced filters, and automation assignee conditions aligned with the shared ownership semantics. - Keep human assignee equality and not-equality filters human-only, even when a human and AgentBot have the same numeric ID. - Exclude AgentBot-owned conversations from both legacy and V2 human auto-assignment, and from unassigned-only Enterprise access. - Preserve AgentBot ownership when Twilio or WhatsApp call flows reuse or accept an assigned conversation. - Emit ownership-change updates when only the AgentBot owner changes, so connected clients refresh queue state. ## Validation - AgentBot assignment changed ownership to the bot, moved the conversation to pending, and removed it from the open queue. - Assignee "is present" returned human- and AgentBot-owned conversations; "is not present" returned only genuinely unassigned conversations. - Automation assignee presence conditions treated AgentBot ownership as present and did not execute the absent-owner path. - Live human-assignee equality and not-equality filters excluded AgentBot-owned conversations, including numeric ID collisions. - A 32-conversation pending queue loaded across pagination with matching totals and no missing or duplicate rows. - AgentBot ownership changes and human takeover updated filtered rows immediately without a reload. - Human takeover opened the conversation and restored the public reply composer; subsequent unassignment kept the conversation open. - An unassigned-only custom-role agent saw only genuinely unassigned conversations and could not see AgentBot-owned conversations. - AgentBot-owned conversations showed the handled-by-bot banner, Take over action, and disabled public reply composer. - New conversations in the connected inbox were assigned to the AgentBot and excluded from human auto-assignment. - Opening an AgentBot-owned conversation did not let the legacy assignment callback or its locked recheck replace the bot. - Moving an AgentBot-owned conversation to an auto-assigning team preserved the bot and did not create a second human owner. - Twilio conference pickup, Twilio outbound reuse, WhatsApp outbound reuse, and inbound WhatsApp acceptance preserved existing AgentBot owners. - Focused ownership, filters, pagination, permissions, unread-count, auto-assignment, frontend, and lint checks passed locally. - GitHub Actions, Docker builds, CircleCI, security checks, and the final Codex review are green on the final head. |
||
|
|
35a5f3390c |
feat: update status on agent bot assignment (#14870)
Assigning a conversation to an Agent Bot now moves it to pending. Assigning a bot-owned pending conversation to a human opens it again, while other assignment changes preserve the existing status. This makes existing Agent Bot ownership behave like an AI handoff without depending on the assignment dropdown UI work. Closes: https://linear.app/chatwoot/issue/CW-7448/apply-agent-bot-assignment-behavior ## Why Agent Bot ownership should remove conversations from the main open queue while the bot is handling them. Explicit human takeover should bring a bot-owned pending conversation back to the open queue and clear the bot owner. ## What changed - Agent Bot assignment clears the human assignee and marks the conversation pending. - Human assignment clears the Agent Bot owner and opens the conversation only when it was bot-owned and pending. - Ordinary human assignment, non-pending bot takeover, and unassignment preserve the existing conversation status. - Manual human takeover uses the existing assignment and status events. Bot-initiated handoffs continue to use the existing bot-handoff event path. ## Validation - Assign an open conversation to an Agent Bot through the assignment API and verify it becomes pending. - Assign that bot-owned pending conversation to a human and verify it becomes open. - Verify ordinary human assignment, non-pending bot takeover, and unassignment do not force a status change. |
||
|
|
0f3bb640f5 |
feat(captain): Add audience and schedule controls for assistants (#14902)
Captain assistants now support **audience** and **schedule** controls, so you can decide *who* an assistant replies to and *when* it's on duty. By default nothing changes, an assistant still responds to every conversation in its connected inboxes but you can now narrow that down. - **Audience**: build a condition tree (contact attributes, conversation attributes, and custom attributes) with and/or groups, mirroring the contact-segment filter semantics. Only conversations whose contact matches the audience get a Captain reply. - **Schedule**: choose when Captain replies — *Anytime*, *During business hours*, or *Outside business hours* (based on each inbox's configured working hours; inboxes without business hours are always covered). When an assistant opts out of a conversation (contact outside the audience, or off-schedule), the conversation is routed to the human queue instead of being parked pending on a silent bot — both on initial creation and on reopen. Fixes https://linear.app/chatwoot/issue/CW-7414/audience-and-availability-controls |Audience|Availability| |--|--| | <img width="1132" height="627" alt="Screenshot 2026-06-30 at 5 52 09 PM" src="https://github.com/user-attachments/assets/866910e0-e1d7-4248-8630-d91afc758688" /> | <img width="1131" height="539" alt="Screenshot 2026-06-30 at 5 52 13 PM" src="https://github.com/user-attachments/assets/aad0d6f7-ceb7-4546-a049-095c5b46b483" /> | ## How to test 1. Open **Captain → Assistants → (an assistant) → Settings**. 2. Under **Audience**, add a condition or condition group (e.g. `Contact language equal_to en`) and save. Start a conversation from a contact that does *not* match — Captain should stay silent and the conversation should land in the human (open) queue instead of pending. 3. With a matching contact, Captain should respond as before. 4. Under **Schedule**, pick **During business hours** (or **Outside business hours**) on an inbox that has working hours configured, and confirm Captain only engages within/outside that window. An empty/`Anytime` schedule always responds. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Co-authored-by: aakashb95 <aakashbakhle@gmail.com> Co-authored-by: iamsivin <iamsivin@gmail.com> |
||
|
|
834e9a4044 | fix(whatsapp): correctly sync Twilio template approval status (#15353) | ||
|
|
0df9508893 |
fix(security): keep inbox access filtering on the participating scope (#15207)
An agent who is removed from an inbox could still see that inbox's conversations under the **Participating** filter. Removing an agent from an inbox does not delete the conversation participant records they already had, and the participating filter was ignoring inbox access entirely — so those conversations stayed visible indefinitely. The filter now respects inbox access like every other conversation filter. ## Linear Ticket - https://linear.app/chatwoot/issue/CW-6923 ## How to reproduce 1. Add an agent to two inboxes, A and B. 2. As that agent, become a participant on a conversation in inbox B (open it, or get added as a participant). 3. Remove the agent from inbox B in Settings → Inboxes → Collaborators. 4. Log in as the agent and open Conversations → Participating. 5. The inbox B conversation is still listed, and is openable. ## What changed `ConversationFinder#filter_by_conversation_type` **replaced** `@conversations` with `current_user.participating_conversations` for the `participating` type, discarding the inbox/permission-filtered scope built up by `Conversations::PermissionFilterService` immediately before it. It now narrows the existing scope by participating ids instead, so permission filtering survives. |
||
|
|
17d927554d |
fix: block the unused Active Storage direct-upload route (#15329)
## Description **Problem.** The default Active Storage upload route, `POST /rails/active_storage/direct_uploads`, is mounted automatically by Rails and requires no authentication. Chatwoot doesn't rely on it, our dashboard and widget uploads all use scoped, authenticated endpoints, so the route just sits there letting anyone create blobs anonymously. **Fix.** Block the built-in route so it returns `403`. Chatwoot's own upload controllers inherit from the same Rails class but are left working, an `instance_of?` check makes the block apply only to the bare route, not to the subclasses that call `super`. Fixes https://linear.app/chatwoot/issue/INF-94 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Added a request spec asserting the bare route returns `403` and creates no blob. Existing widget and conversation direct-upload specs still pass, confirming the scoped endpoints are unaffected. 13 examples, 0 failures across the three direct-upload specs; rubocop clean. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> |
||
|
|
430c5cfef0 |
fix(whatsapp): refresh inboxes when opening new conversation composer (#15337)
When an agent starts a new conversation, the WhatsApp template picker in the composer only shows templates that were already loaded into the frontend store — it doesn't refresh when the composer opens. If a template sync completed after the store was last populated, the newly synced template shows up on the inbox's Settings > Templates page (which always refetches on load) but not in the New Conversation composer, since that view relied solely on the account-cache-invalidated websocket event, which doesn't always reach an already-open session in time. ## What changed - `ComposeConversation.vue` now dispatches a cache-aware `inboxes/get` refetch every time the composer popover opens, so the WhatsApp template list is current before an agent picks a template to message a customer. The refetch checks the account's cache key first and only re-pulls the full inbox list when it's actually stale, so it stays cheap in the common case. ## How to reproduce 1. Sync/update WhatsApp templates for an inbox (e.g. via Settings > Inboxes > [WhatsApp inbox] > Sync Templates). 2. Without reloading the page, open the New Conversation composer for that inbox and check the WhatsApp template picker — a newly synced template may be missing until this fix. --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> |
||
|
|
a72b49a981 |
fix: validate days_before filter values before date arithmetic (#15331)
## Description Follow-up to #15319, addressing the non-blocking review notes and the Ito QA finding there. The `days_before` filter value went straight through `to_i`, which never fails: `-1` moves the cutoff into the future and matches every conversation, and a non-numeric string becomes `0`. The value is now parsed as a base-10 integer and must fall within the UI-supported `1..998` range; anything else raises the existing `CustomExceptions::CustomFilter::InvalidValue`, which the controller already turns into a client error. Also corrects an existing weak spec that sent `3` days but computed its expectation with `2` days, passing only because the seeded data made both counts equal. It now sends `2` and exercises the exclusive boundary. Refs https://linear.app/chatwoot/issue/CW-7832 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - New specs: invalid values (`-1`, `abc`, `0`, `999`) raise `InvalidValue`; string values parse as base 10 (`'02'` means 2 days, not octal). - `bundle exec rspec spec/services/conversations/filter_service_spec.rb` (36 examples, 0 failures). ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules |
||
|
|
3b74f9c359 |
fix: prevent Captain bot collisions (#15324)
Prevents Captain from being scheduled for replies or inactive-conversation resolution when an inbox already has an active AgentBot or Dialogflow integration. ## Closes [CW-7834](https://linear.app/chatwoot/issue/CW-7834/prevent-captain-from-processing-agentbot-and-dialogflow-conversations) ## Why Captain and an external inbox bot could both process conversations from the same inbox. ## What this change does - Distinguishes external inbox bots from Captain in the existing bot predicate. - Skips Captain reply scheduling when an external bot is active. - Skips Captain inactive-resolution scheduling when an external bot is active. ## Validation - Connect an AgentBot to a Captain-enabled inbox and confirm Captain does not reply or schedule inactive resolution. - Enable Dialogflow on a Captain-enabled inbox and confirm Captain does not reply. - Remove the external bot integration and confirm Captain resumes normal processing. --------- Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> |
||
|
|
781943867b |
fix(whatsapp): preserve and display flow responses (#15279)
WhatsApp Flow submissions currently reach Chatwoot as `interactive.nfm_reply` messages, but their structured answers are discarded. Agents see an empty message and outbound webhooks cannot access the submitted data. This change preserves the Flow response in message content attributes and renders a readable response card in the conversation. Existing WhatsApp messages and other interactive message types remain unchanged. Fixes https://github.com/chatwoot/chatwoot/issues/13970 <img width="1234" height="1350" alt="CleanShot 2026-08-04 at 00 40 14@2x" src="https://github.com/user-attachments/assets/884b3a5c-bab3-4196-8037-776e6c41ab2a" /> ### How to reproduce 1. Send an approved WhatsApp template containing a Flow button. 2. Complete and submit the Flow from WhatsApp. 3. Open the conversation in Chatwoot. 4. Observe that the incoming message has no visible content and the submitted fields are absent from the message webhook. ### What changed - Parse `interactive.nfm_reply.response_json` for incoming WhatsApp Cloud messages. - Store the Flow name, body, and structured response under `content_attributes.whatsapp_flow_response`. - Add a dedicated conversation bubble that formats submitted field names and values. - Preserve original response keys while converting message attributes to the frontend shape. - Keep `flow_token` available in stored/webhook data while omitting it from the agent-facing card. - Add focused backend, helper, and component regression coverage. ### How to test 1. Send a WhatsApp Flow template to a contact. 2. Submit the Flow with multiple field types, including free text and a selection. 3. Confirm the incoming conversation message displays each submitted field and value. 4. Confirm an empty-answer Flow still renders a visible “Submitted a flow response” message. 5. Inspect the `message_created` webhook and confirm the structured response is present in `content_attributes.whatsapp_flow_response.response_json`. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |
||
|
|
56be133bb0 |
feat(data-imports): add Freshdesk migration (1/3) (#15261)
## Description Adds Freshdesk as an integration import source so administrators can validate a Freshdesk domain and API key, then import contacts, tickets, public replies, customer replies, and private notes while tracking progress from Data Imports. The integration has now been validated against a live Freshdesk trial tenant with contacts, Web Chat and phone tickets, public replies, customer replies, a private note, pagination, requester expansion, and attachment metadata. That validation found and fixed the current Web Chat source mapping and prevented the ticket description from duplicating the initial Web Chat message. Related: #15116 ## Closes Closes [CW-7639](https://linear.app/chatwoot/issue/CW-7639/freshdesk-freshworks-migration) ## Type of change - [x] New feature (non-breaking change which adds functionality) ## What changed - Added a shared source adapter, importer, job, retry, restart, creation, and placeholder inbox contract used by Intercom and Freshdesk. - Added Freshdesk API authentication, contact and ticket pagination, requester expansion, conversation retrieval, normalization, channel grouping, and error handling. - Added current Freshdesk source identifiers through SMS, including Web Chat source `15`, and grouped equivalent sources into placeholder inboxes. - Used Web Chat conversation events as the complete message history so the generated ticket description does not duplicate the initial customer message. - Preserved Freshdesk ticket subjects in source metadata and added a sanitized live-derived Web Chat fixture with structured bodies and attachment metadata. - Added Freshdesk selection, domain and API key validation, and provider-neutral import status handling in the Data Imports UI. ## How to test 1. Enable the data_import feature for an account and open Settings > Data > New import. 2. Select Freshdesk and enter a Freshdesk domain and API key. 3. Select contacts and/or conversations, validate the credentials, and start the import. 4. Confirm progress is displayed and imported tickets appear as resolved conversations in Freshdesk placeholder inboxes with public replies and private notes preserved. 5. Verify Web Chat tickets appear in the Chat placeholder inbox and the initial customer message is imported once. 6. Verify an abandoned import can be restarted and a stalled import can be retried. ## Current scope - **Product decision:** Attachment binaries are intentionally not imported in the current migration scope. Attachment metadata is preserved and messages include a skipped-attachment marker. - Adaptive Retry-After scheduling and handling the 30,000-ticket listing ceiling are covered by stacked follow-up PRs. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules |
||
|
|
bab2f99004 |
perf: reduce per-request work in conversation filter endpoint (#15321)
## Description Every conversation filter request runs three separate unbounded COUNT queries over the filtered set (mine, unassigned, all) and eager-loads every message of every conversation on the page. On large accounts this adds a fixed 1-2s of latency per request regardless of the filter. This PR trims both: - The three counts are now computed in a single pass using `COUNT(*) FILTER (...)` aggregates. Response shape and count semantics are unchanged. - The `:messages` eager-load is removed from the filter base relation. The list payload fetches messages through scoped queries (last message, last non-activity message, unread messages), which never read the preloaded collection, so it was loaded and discarded on every request. Fixes https://linear.app/chatwoot/issue/CW-7830 |
||
|
|
d01ad1fc97 |
fix: handle ActionController::Parameters in days_before filter (#15319)
## Description The `days_before` filter operator in `FilterService#days_before_filter_query` calls `with_indifferent_access` directly on the query hash. In production the filter payload arrives as `ActionController::Parameters` (the controller passes `params.permit!`), which does not respond to `with_indifferent_access`, so every conversation filter request using `days_before` raises `NoMethodError` and returns a 500. Existing specs pass plain hashes with `with_indifferent_access`, which is why this was never caught. This PR normalizes the query hash via `to_h` first (permitted parameters convert to a `HashWithIndifferentAccess`, plain hashes are unaffected) and adds a regression spec that builds the payload as `ActionController::Parameters`, mirroring the controller. Fixes https://linear.app/chatwoot/issue/CW-7832 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Added a regression spec that passes the filter payload as permitted `ActionController::Parameters` with the `days_before` operator. It reproduces the `NoMethodError` on the current develop branch and passes with this change. - `bundle exec rspec spec/services/conversations/filter_service_spec.rb` (31 examples, 0 failures) - `bundle exec rspec spec/services/conversations/filter_service_frontend_alignment_spec.rb` (10 examples, 0 failures) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules |
||
|
|
c82b75dcce |
feat: add manage-notification-preferences footer to agent notification emails (#15192)
## Description Agent notification emails (new conversation, assignment, mention, new message, SLA misses) currently carry no indication of why the recipient received them or how to turn them off. When recipients cannot easily manage these emails, some mark them as spam, which hurts sender reputation and overall deliverability. This adds a short footer line to agent notification emails only: > You're receiving this email because email notifications are enabled for your account. **Manage notification preferences**. The link points the recipient to their dashboard profile notification settings page (`/app/accounts/:account_id/profile/settings`) so they can disable notifications instead of marking the email as spam. This is a navigational link only, not a one-click unsubscribe (a real unsubscribe flow is a separate future change). Scoping: the mailer layout (`app/views/layouts/mailer/base.liquid`) is shared by all mailers via `ApplicationMailer`. To keep the footer on agent notification emails only, `AgentNotifications::ConversationNotificationsMailer` exposes a `notification_settings_url` liquid local (built from the existing `app_account_url` route helper), and the layout renders the footer line only when that local is present. Transactional and other emails do not set it, so they are unaffected. The enterprise SLA notification methods prepend into the same mailer class, so they inherit the footer automatically. Part of https://linear.app/chatwoot/issue/CW-7752 ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - `bundle exec rspec spec/mailers/agent_notifications/conversation_notifications_mailer_spec.rb spec/mailers/confirmation_instructions_spec.rb` — 26 examples, 0 failures. Asserts the footer link and settings URL are present in an agent notification email and absent from a non-notification (confirmation) email. - `bundle exec rspec spec/enterprise/mailers/enterprise/agent_notifications/conversation_notifications_mailer_spec.rb` — 6 examples, 0 failures (SLA notification emails). ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules |
||
|
|
6f6ddc5636 |
fix: flaky AppliedSla scope spec after Rails reloads (#15327)
CI no longer fails when the AppliedSla scope spec runs after Rails reloads model classes. The spec now compares record IDs, so it checks which SLA records the scope returns without depending on Ruby class identity. ## How to reproduce Run the report builder and assignment policy controller specs before `spec/enterprise/models/applied_sla_spec.rb` in the same RSpec process. Before the fix, the scope returns records with the expected IDs, but the assertion rejects them because FactoryBot and Rails use different `AppliedSla` class objects after the reload. ## What changed The spec reads the IDs returned by `with_sla_applicable_conversation` and checks the normal conversation, missing contact, and blocked contact cases with those IDs. The affected CircleCI shard passes locally after the change. |
||
|
|
d1a6ea5798 |
fix: normalize mexico whatsapp phone numbers (#14174)
## Summary This fixes WhatsApp phone number normalization for Mexico numbers when using Twilio as a provider. Twilio may send incoming Mexico WhatsApp phone numbers with an extra `1` after the country code, for example: - stored contact inbox source: `whatsapp:+525512345678` - incoming message source: `whatsapp:+5215512345678` Before this change, Chatwoot could treat these as different source IDs and create a duplicate contact/conversation path instead of matching the existing one. ## What changed - added a Mexico-specific phone normalizer - normalize `+521...` to `+52...` for contact matching - kept the original incoming source ID when creating a brand new contact inbox ## Why this approach Chatwoot already has country-specific normalization for WhatsApp numbers in the normalization service. This follows the same pattern and keeps the change small and isolated. ## Tests Added specs covering: - matching an existing Mexico WhatsApp contact inbox when Twilio sends `+521...` - preserving the original source ID when no matching contact inbox exists and a new one is created Fixes https://linear.app/chatwoot/issue/CW-6844/fix-normalize-mexico-whatsapp-phone-numbers-on-twilio-to-prevent --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
342f0a399c |
fix(captain): resolve V2 FAQ citations from trusted sources (#15159)
Captain V2 now adds FAQ citations from a structured model response. The model returns ordered response parts with citation indexes, and Chatwoot turns only trusted indexes into customer links. ## Before Captain V2 asked the model to copy text markers such as `[[faq:1]]`. Chatwoot used one regular expression to replace those markers with links in the outgoing message and another regular expression to remove the rendered links before the next model turn. Long conversations depended on parsing the customer message to recover plain model context. ## After The FAQ lookup tool now gives each eligible source document a numeric index and never gives the model a URL. FAQ results from the same document reuse the same index. The model returns `response_parts`, where each part contains customer text and the supporting citation indexes. Chatwoot checks every index against the document IDs registered during the current run. Only stored HTTP or HTTPS web-document links without embedded credentials can appear in the customer reply. Blank links, PDF sources, attachments, non-HTTP storage links, and unknown indexes do not create links. Sources receive display numbers in the order they first appear, and repeated sources keep the same display number. Chatwoot saves the structured response parts with each newly generated Captain message. Later Captain V2 turns use the saved plain text for those messages, so they never need to parse rendered links. Existing messages remain unchanged and continue to use their stored content. When citations are disabled, Chatwoot clears citation indexes before it returns or saves the response. Captain V1, Copilot, legacy prompts, legacy tools, and the playground response contract are unchanged. The playground continues to show the plain `response` field. ## Closes [AI-138](https://linear.app/chatwoot/issue/AI-138/faq-citation-fix) ## How to test 1. Open a conversation handled by a Captain V2 assistant and turn citations off. Ask a greeting, an FAQ question, a code question, and a follow up question. Confirm that the assistant answers normally and shows no source links. 2. Turn citations on and ask a question that matches one public web document. Confirm that the reply shows the stored public link after the supported text. 3. Ask a question that needs two public web documents. Confirm that the response order stays correct, each link appears after the supported text, and repeated sources keep the same display number. 4. Ask a question that retrieves several FAQ results from one document. Confirm that the reply shows the document once at each supported response part rather than exposing separate FAQ sources. 5. Ask a question supported by a PDF, attachment, blank link, or non-HTTP storage link. Confirm that Captain can use the information but does not show a customer link. 6. Ask for a fenced code example with a citation. Confirm that the code block stays complete and the citation appears after the closing fence. 7. Continue the conversation with a follow up question. Confirm that Captain uses the earlier plain response text and does not receive or repeat rendered citation links. 8. Test a scenario handoff in a conversation. Confirm that the handoff and final response still work. |
||
|
|
9177fffa71 | fix: enforce required conversation attributes on resolve for macros (#15232) | ||
|
|
8448001fdc |
fix(security): gate SLA APIs and automation on the sla feature (#15209)
SLA is a premium feature, but several SLA surfaces never checked the
account's `sla` flag. An account without SLA — or one whose plan was
downgraded and had the flag revoked — could still read SLA breach
reporting, attach an SLA policy to a conversation through the
conversation update endpoint, and keep applying SLA policies from
automation rules. All three paths now require the feature, matching the
SLA policies API which already checked it.
## How to reproduce
1. Disable the `sla` feature for an account that has SLA policies and
applied SLAs.
2. `GET /api/v1/accounts/{id}/applied_slas/metrics` (also `index` and
`download`) — returns the account's SLA breach data instead of denying.
3. `PATCH /api/v1/accounts/{id}/conversations/{display_id}` with
`sla_policy_id` — the policy is attached.
4. Trigger an automation rule with an "Add SLA" action — the SLA is
applied and starts tracking.
## What changed
- `Api::V1::Accounts::AppliedSlasController` gains the same
`ensure_sla_feature_enabled` guard the SLA policies controller uses,
covering `index`, `metrics` and `download`.
-
`Enterprise::Api::V1::Accounts::ConversationsController#permitted_update_params`
only permits `sla_policy_id` when the feature is enabled. When it is off
the parameter is dropped, so an existing SLA association is preserved
rather than cleared.
- `Enterprise::ActionService#add_sla` returns early when the feature is
off, so automation rules stop applying SLAs on revoked accounts.
|
||
|
|
e7ded47753 |
feat: move Captain auto-resolve policy to assistants (1/5) (#15299)
Captain auto-resolve policy is now owned by each assistant. Existing assistants first read an assistant-level setting, fall back to the current account setting during rollout, and are backfilled asynchronously so behavior is preserved. ## Closes [AI-163](https://linear.app/chatwoot/issue/AI-163) ## How to test 1. Configure different auto-resolve modes on two assistants in the same account through the API. 2. Confirm each assistant follows its own mode when the inactivity job runs. 3. Confirm an assistant without the new setting follows the existing account mode. 4. Run the migration job and confirm the account mode is copied without overwriting an existing assistant mode. 5. Confirm evaluated mode falls back to time-based resolution when the `captain_tasks` capability is unavailable. ## What changed - Added assistant-level `disabled`, `legacy`, and `evaluated` policy storage and validation. - Updated scheduling and resolution runtime reads to use the assistant policy. - Added a compatibility fallback and asynchronous backfill from the account setting. - Preserved the account-level `captain_tasks` capability gate for evaluation. - Kept this foundation independent of the Captain V2 guard; the timer and advanced settings in stacks 2–5 are Captain V2-only. Stack 1 of 5. This is the base for #15303. |
||
|
|
0ad2780972 |
fix(conversations): prevent duplicate assignment activity from concurrent assignment writes (#15275)
Conversations could get reassigned repeatedly in quick succession — bouncing between agents with several "Assigned to X" activity messages in a row — whenever an assignment write raced against another assignment write on the same conversation (an automation rule vs. Assignment V2's auto-assign, two manual assignment clicks, two concurrent status transitions triggering legacy round-robin, etc). This was reproducible with or without Assignment V2 enabled; it wasn't a V2-specific issue, just more visible under message bursts. Assignment V2's own job-vs-job race was already fixed separately (#14495) — this PR covers the other writers that don't check the conversation's current state before overwriting it. ## How to reproduce Configure an automation rule that assigns an agent/team on `message_created`, then send a burst of messages on one conversation from a channel/inbox with concurrent message delivery (or race it against another assignment source — a manual assignment click, or a status change that triggers legacy auto-assignment). The conversation's assignee flips between agents multiple times, each producing its own activity message, even though later writes were often redundant (setting the assignee to a value it already had, from the writer's stale point of view). ## What changed Three assignment write paths now take a row lock (`with_lock`) before writing, so a concurrent writer re-reads the conversation's true current state before deciding whether a write is actually needed: - `ActionService#assign_agent`/`#assign_team` (and the corresponding unassign methods) — used by automation rules, macros, and delayed automations. - `Conversations::AssignmentService#assign_agent`/`#assign_agent_bot` — the dashboard's assignee dropdown (human agent or AgentBot). - `AutoAssignment::AgentAssignmentService#perform` — the legacy (non-V2) round-robin auto-assignment path. A write that would just reapply an already-current value becomes a genuine no-op instead of producing a redundant `UPDATE` and duplicate activity message. Legitimate reassignment (a real change in target, or reassignment away from an agent who lost inbox access) is unaffected. |
||
|
|
94e9727eb4 |
feat: move Captain conversation outcomes to episode grain [CW-7792] (#15315)
Moves Captain conversation outcomes from one row per conversation to one row per **engagement episode**: a new row each time demand for Captain (re)starts - first eligible message, a reopen after resolution, or (reserved) explicit assignment. Each episode has its own demand anchor, window, and trigger, so returning customers count as new demand and later cycles can't overwrite an earlier episode's handoff reason, resolution, or CSAT. Also adds `conversation_outcomes` associations on Account, Inbox, Conversation, and Captain::Assistant. ## Why this wasn't in #15233 The episode design came out of reviewing the wiring PR: per-conversation grain couldn't answer per-cycle questions without a patch per field. The table is unreleased with no writers, so changing the grain now is a pure schema swap - and landing it first means the tracker gets reviewed against the final model. ## What changed - Adds `episode_trigger`, `started_at`, `ended_at` - Drops `reopen_count` and `last_reopened_at` - reopens are episode rows now - Uniqueness moves from `(account, assistant, conversation)` to per-boundary `(account, conversation, started_at)` - Two partial unique indexes: one open episode per conversation, one initial episode per stream - Model: trigger enum, `started_at` uniqueness validation, `chronological`/`covering` scopes |
||
|
|
1c28df49e5 |
feat(whatsapp): expose cached Twilio templates (#15311)
The cached inbox template endpoint currently serves only native WhatsApp channels. This draft extends the same read-only endpoint to Twilio WhatsApp inboxes and adds last-sync metadata for both providers, so the template listing can use one stable contract without making provider requests. Non-WhatsApp inbox behavior remains unchanged. Related: https://linear.app/chatwoot/issue/PLA-193/add-whatsapp-template-listing-to-account-settings ### Things to know - This is stack 1 of 2 and contains only the API contract needed by the settings UI in #15312. - Template data remains cache-only; the endpoint does not call Meta or Twilio. - Native WhatsApp templates are filtered by `name`; Twilio Content Templates are filtered by `friendly_name`. ### How to test 1. Request the message templates endpoint for a native WhatsApp inbox and confirm it returns the cached templates plus `meta.last_updated_at`. 2. Request it for a Twilio WhatsApp inbox and confirm it returns cached Content Templates plus `meta.last_updated_at`. 3. Pass a template name and confirm only the matching provider template is returned. 4. Request it for a non-WhatsApp inbox and confirm the endpoint returns an unprocessable entity response. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |
||
|
|
e3ce2772fe |
fix: preserve original filenames for Telegram inbound attachments (#15071)
## Description Telegram inbound attachments were stored under a generic, renamed filename (e.g. `file_5.pdf`) instead of the name the sender actually uploaded (e.g. `Quarterly-Report-2025.pdf`). **Root cause:** In `Telegram::IncomingMessageService#attach_files`, the attachment filename was taken from the `Down`-downloaded file's `original_filename`, which `Down` derives from the Telegram file-server download URL. That URL is built from Telegram's `getFile` response, whose `file_path` is Telegram's *internal* storage path — not the sender's chosen name. The real name is present in the webhook payload as `file_name` on the `document` / `audio` / `video` object, but it was never read. Because `Attachment#set_extension` derives the stored `extension` from the filename, this also corrupted the recorded extension for documents whose Telegram path lacked a proper one. **Fix:** Prefer the payload's `file_name`, falling back to the downloaded name when it's absent (photos, stickers, voice notes — which Telegram sends without a `file_name`). This mirrors how the Line channel (`message['fileName']`) and WhatsApp Cloud channel already handle inbound attachment filenames. ```ruby filename: file[:file_name].presence || attachment_file.original_filename, ``` Fixes #15070 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Added/updated specs in `spec/services/telegram/incoming_message_service_spec.rb`: - Extended the existing document-message example to assert the stored attachment retains the original payload filename (`Screenshot 2021-09-27 at 2.01.14 PM.png`) rather than Telegram's internal download name. - Added a fallback example (a `photo` payload, which carries no `file_name`) asserting the attachment is still created and the filename falls back to the downloaded name — guarding the `.presence || …` branch and confirming no regression for media types without a `file_name`. > Note: my local machine does not have the Ruby/Redis toolchain to run the suite, so I have not run RSpec/RuboCop locally — I'm relying on CI to validate. Suggested command for reviewers: `bundle exec rspec spec/services/telegram/incoming_message_service_spec.rb`. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] I have added tests that prove my fix is effective - [ ] New and existing unit tests pass locally with my changes (unable to run locally — see note above; relying on CI) |
||
|
|
3633dd44fb |
perf: keep planner off misestimated inbox scan for label filter queries (#15264)
## Description Label-based conversation filters (custom views / folders) can time out for non-admin agents on large accounts. The permission scoping adds `conversations.inbox_id IN (subquery)` to the filter query, and Postgres misestimates the row count for the account+inbox combination by several orders of magnitude. It then drives the query through an inbox index scan over the account's entire conversation set instead of starting from the label's taggings, which are often only a handful of rows. The query exceeds the request timeout and the folder never loads. This change adds a planner hint to `Conversations::PermissionFilterService`: when enabled, the inbox scoping condition is written as `(conversations.inbox_id + 0) IN (subquery)`, which returns identical rows but is not indexable, so the planner cannot choose the misestimated path. `Conversations::FilterService` enables the hint only when the filter payload contains a `labels` condition. All other callers of the permission service are unchanged, since the bare condition is the right plan for unfiltered conversation lists. Observed on a ~365k-conversation account (worst case, cold cache): label filter for an agent went from exceeding 60s to ~2ms. Popular labels (~154k taggings) show no regression. Admin queries are untouched. Fixes https://linear.app/chatwoot/issue/CW-7787 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Specs for row-equivalence of the hinted scoping, admin behavior, and hint application scoped to label filters only - `EXPLAIN (ANALYZE, BUFFERS)` comparison on a production-scale dataset for rare-label, popular-label, and no-label query shapes, agent and admin, with generic plans matching prepared-statement behavior |
||
|
|
871130f566 |
feat: add Captain conversation outcome model [CW-7792] (#15233)
Adds the `Captain::ConversationOutcome` model and its table: one row per (account, assistant, conversation) that folds Captain lifecycle events into flat facts for the upcoming value metrics report. This is the second PR in the outcomes stack, on top of the lifecycle event layer (#15213); the tracker and listener that populate it come next. The table deliberately stores only timestamps, counts, and the handoff reason. Every classification and duration (coverage, autonomous vs assisted, durable resolution, resolution time) is derived at query time in the stats layer, so definitions can change later without backfills. The row is created on the first qualifying customer message, making `created_at` the demand-start anchor. Commit messages carry the field-by-field rationale for what was left out. | Field | Meaning | | --- | --- | | `account_id`, `assistant_id`, `conversation_id`, `inbox_id` | Reporting dimensions; unique on the first three | | `first_captain_reply_at` / `last_captain_reply_at` | First and latest public Captain reply | | `captain_reply_count` | Public Captain replies in the conversation | | `first_human_reply_at` | First public human agent reply, used to classify assisted resolutions | | `handoff_at` | When Captain handed the conversation to a human | | `handoff_reason_category` | Why it handed off (customer_request, missing_knowledge, unsupported_request, policy_restriction, tool_failure, pending_clarification, usage_limit) | | `resolved_at` | When the conversation was resolved | | `last_reopened_at` / `reopen_count` | Reopen facts backing reopen rate and durable-resolution checks | | `csat_rating` / `csat_received_at` | CSAT for Captain-involved conversations | |
||
|
|
94d7ccf5e9 |
fix(security): gate custom role APIs on the custom_roles feature (#15208)
Custom Roles is a premium feature, but the account's `custom_roles` flag
was never checked by the API. An account on a plan without Custom Roles
— or one whose plan was downgraded and had the flag revoked — could
still list, create, edit and delete custom roles, and could still attach
a `custom_role_id` to an agent through the agents API. Both paths now
require the feature.
## How to reproduce
1. Disable the `custom_roles` feature for an account (Super Admin →
Account → Features, or a plan downgrade).
2. `GET /api/v1/accounts/{id}/custom_roles` — returns 200 with the
account's roles instead of denying.
3. `PATCH /api/v1/accounts/{id}/agents/{agent_id}` with `custom_role_id`
— the role is assigned.
## What changed
- `Api::V1::Accounts::CustomRolesController` gains a
`ensure_custom_roles_feature_enabled` guard, matching the shape already
used by the SLA policies controller.
-
`Enterprise::Api::V1::Accounts::AgentsController#associate_agent_with_custom_role`
ignores `custom_role_id` when the feature is off, rather than writing
it.
Existing custom role assignments are deliberately left untouched — they
are evaluated at request time by `Enterprise::AccountUser#permissions`
and the conversation policy, where custom roles mostly *narrow* an
agent's scope. Dropping them on revocation would widen conversation
visibility for restricted agents, which is a separate product decision.
---------
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
|
||
|
|
7142bc43a5 |
feat(whatsapp): expose message templates through the API (#15277)
External integrations can send WhatsApp template messages, but they do not have a focused API to discover the templates available for an inbox. This adds an authenticated endpoint that lists the inbox's cached WhatsApp templates and optionally filters them by exact template name. Existing inbox responses and template synchronization behavior remain unchanged. Fixes https://github.com/chatwoot/chatwoot/issues/13959 ### Things to know - The endpoint returns templates already synchronized and cached for a direct WhatsApp inbox; it does not make a new provider request. - Non-WhatsApp inboxes return an unprocessable entity response. ### How to test 1. Authenticate as a user assigned to a WhatsApp inbox. 2. Request `GET /api/v1/accounts/:account_id/inboxes/:id/message_templates` and verify the response contains the inbox's templates under `payload`. 3. Repeat the request with `?name=<template_name>` and verify only the exact matching template is returned. 4. Request the endpoint for a non-WhatsApp inbox and verify it returns an unprocessable entity response. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |
||
|
|
e1270a4ef8 |
fix(whatsapp): render template values in conversation transcript (#15255)
WhatsApp template messages can be delivered with the correct variable
values while the Chatwoot conversation shows numeric placeholders
instead. This affects template sends where the client submits the raw
template body together with valid processed parameters; WhatsApp
delivery itself remains unchanged.
The message model currently passes outgoing content through Liquid
before saving it. Liquid interprets positional WhatsApp placeholders
such as `{{1}}` and `{{2}}` as numeric expressions, even though the
WhatsApp sender independently uses `processed_params.body` to build the
provider payload.
Fixes
https://linear.app/chatwoot/issue/PLA-192/render-whatsapp-template-values-in-conversation-transcripts
### What changed
For WhatsApp template messages, Chatwoot now substitutes positional or
named body placeholders from `processed_params.body` before saving the
transcript. Missing values remain visible as placeholders, and ordinary
outgoing Liquid messages continue through the existing rendering path.
The existing message model regression suite remains green, and the
reported payload now persists as `Hello Ahmad, Furqan is your contact.`
### Things to know
This corrects newly created messages. Existing conversation messages
that were already stored as `1`, `2`, and so on are not backfilled.
### How to reproduce
1. Open a WhatsApp conversation outside the 24-hour messaging window.
2. Send a positional template whose body contains `{{1}}` and `{{2}}`.
3. Supply values for both parameters while submitting the original
template body as the message content.
4. Observe that WhatsApp delivers the substituted values, while the
Chatwoot transcript shows `1` and `2`.
### How to test
1. Select a WhatsApp template with two body variables.
2. Enter `Ahmad` and `Furqan` as the values and send the message.
3. Confirm the recipient receives the substituted template.
4. Confirm the Chatwoot conversation also displays `Ahmad` and `Furqan`
instead of the numeric placeholders.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
|
||
|
|
6347ad1926 | feat: add analytics providers to help center (#15124) | ||
|
|
7981e2cc75 |
refactor: introduce normalized Captain lifecycle events [CW-7792] (#15213)
This introduces a small event layer for the Captain V2 conversation lifecycle. A new `Captain::ConversationEvents` facade dispatches five normalized events (`captain.conversation.engaged`, `captain.conversation.handed_off`, `captain.conversation.resolved`, `captain.response.completed`, `captain.response.failed`) from the points where Captain engages a conversation, replies, fails, hands off, or auto-resolves. Each event carries the conversation, assistant, timestamp, and a `source`/`reason_category` where relevant. ## Why this, why now The Captain V2 flow is about to gain several observers at once: conversation outcome tracking, agent session capture, and analytics all need to know when Captain engages, replies, fails, hands off, or resolves. Wiring each of them directly into `ResponseBuilderJob`, `HookExecutionService`, and the tools would tangle secondary bookkeeping into the paths that deliver customer-facing behavior, and every future consumer would deepen that. Landing the event layer first as its own PR means the flow announces these moments once and stays otherwise untouched: customer-visible behavior (messages, status changes, handoffs, usage enforcement) remains synchronous, while secondary effects subscribe through listeners. The upcoming conversation outcomes PR then reduces to a listener plus a model instead of another round of edits to the core flow, which is why this ships now, before that work merges. The existing inference reporting behavior is folded into this layer: the `conversation.captain_inference_*` events and their dispatch helpers on `Enterprise::Conversation` are removed, and a dedicated `Captain::ReportingEventListener` (registered on the enterprise async dispatcher) maps `source: 'inference'` events to the same stored reporting event names, so recorded analytics and the assistant stats builder are unaffected. ## What changed - New `Captain::ConversationEvents` facade and event type constants - Event emission from `HookExecutionService` (engagement, usage-limit handoff), `ResponseBuilderJob` (response completed/failed, generation-failure handoff), `HandoffTool` (tool handoff), and `InboxPendingConversationsResolutionJob` (inference resolved/handoff) - A dedicated `Captain::ReportingEventListener` preserves inference reporting events through the new event names, removing captain logic from the OSS listener |
||
|
|
b05f22da8d |
fix(captain): default paid accounts to V2 (#15262)
Paid Chatwoot Cloud accounts now receive Captain V2 during plan reconciliation unless they are explicitly held on Captain V1. Older accounts could otherwise start on V1 when they became paid after the V2 rollout because only newly created accounts carried the rollout eligibility value. ## Closes No linked issue. ## How to reproduce 1. Start with a cloud account created before the Captain V2 rollout. 2. Upgrade the account from the default plan to a paid plan. 3. Reconcile the Stripe subscription. 4. Confirm that Captain is enabled but Captain V2 remains disabled. ## What changed 1. Treat a missing rollout eligibility value as eligible for Captain V2 on paid plans. 2. Keep Captain V2 disabled when the rollout eligibility value is explicitly set to false. 3. Keep the default plan behavior unchanged. 4. Update the billing reconciliation specs to cover existing paid accounts, new accounts, and explicit V1 exceptions. |
||
|
|
ecb7a44f07 |
fix(email): resolve reply recipients from the message being sent (#15194)
When an agent loops a new address into an ongoing email thread and then adds a private note, the reply currently goes out with no Cc, and with To falling back to the conversation contact. The newly added person never receives the mail. This change makes an outgoing email keep the To/Cc/Bcc that were entered on that reply, no matter what is added to the conversation afterwards. Closes #15193 ## How to reproduce 1. Open a conversation on an Email inbox. 2. Add a new address to **Cc** and send a reply. 3. Immediately add a private note to the same conversation. 4. Inspect the delivered mail: `Cc` is empty and `To` is the conversation contact instead of the addresses entered on the reply. ## What changed `ConversationReplyMailer#cc_bcc_emails` and `#to_emails_from_content_attributes` read the addresses from `@conversation.messages.outgoing.last` rather than from the message they were handed. Replies are delivered asynchronously (`SendReplyJob.perform_later`, and `wait: 2.seconds` when the message has attachments), so any outgoing message created in that window replaces the recipients of a mail that is already queued. A private note is the easiest way to hit it: private notes are outgoing messages, and on an Email inbox `Messages::MessageBuilder#process_emails` stores them with empty `to_emails` / `cc_emails` / `bcc_emails`. Both lookups now go through the existing `current_message` helper (`@message || @conversation.messages.outgoing.last`), which the `from` and `reply_to` builders already use. `email_reply` sets `@message`, so it resolves the recipients of the message being delivered; `reply_with_summary` and `reply_without_summary` leave `@message` nil and keep their current behaviour. Added a spec covering the private-note case in `spec/mailers/conversation_reply_mailer_spec.rb`. ## Note I could not run the Ruby test suite locally — there is no Ruby toolchain in the environment I worked in, so the new spec has not been executed on my side. The change and the spec were verified by reading the code paths (`Message#send_reply`, `Email::SendOnEmailService`, `Messages::MessageBuilder#process_emails`, `ConversationReplyMailerHelper#prepare_mail`). Deferring to CI for the actual run. |
||
|
|
0c606babea |
feat: time based automation (#15022)
## Description Add automations that trigger based on how long a conversation has been in a given state. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - UI flows - Specs (https://github.com/chatwoot/chatwoot/pull/15021) ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> |
||
|
|
38a317962b |
fix: Captain handles message bursts with a single reply (#15133)
Captain now handles a burst of customer messages with one reply. If more messages arrive before Captain replies, the latest job uses the full conversation history. The change applies only to Captain V2 and works across every channel that Captain supports. Blocked on: https://github.com/chatwoot/chatwoot/pull/15212 ## Closes Closes https://github.com/chatwoot/chatwoot/issues/14545 ## How to reproduce 1. Start a pending conversation with Captain V2. 2. Send several messages while Captain is preparing a reply. 3. Captain can generate and send a separate reply for each message. ## What changed * Each Captain V2 job records the incoming message that started it. * A job stops before generation if a newer message already exists. * Captain discards a generated reply if a newer message arrived during generation. * The latest job replies using the full conversation history. * Langfuse records whether a generation was discarded and whether a customer credit was used. * Captain V1 keeps its existing behavior. ## Tradeoffs Discarded generations still cost money. Message bursts can also increase background job work and model provider load. A continuous stream of incoming messages can delay the reply until one generation finishes without a newer message. A small timing window remains if a message arrives after the final check and before Captain saves the reply. A handoff also cannot be undone if a newer message arrives after Captain has already changed the conversation status. ## How to test 1. Enable Captain V2 and start a pending Captain conversation. 2. Send several messages while Captain is preparing a reply. 3. Confirm that Captain sends one reply based on the full message history. 4. Confirm that Langfuse marks discarded runs with `discarded=true` and `credit_used=false`. 5. Disable Captain V2 and confirm that Captain V1 behavior is unchanged. --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> |
||
|
|
9d769dfcdd |
fix(whatsapp): collect text header parameters (#15199)
WhatsApp templates with variables in both a text header and body currently show inputs only for the body. Agents therefore cannot provide the header value in the template composer, even though the API and backend support the corresponding `processed_params.header` payload. The composer now displays text-header variables separately, previews their substituted values, and sends them alongside body parameters. Templates using media headers remain unchanged. Related: https://github.com/chatwoot/utils/pull/65 ### Things to know This PR consumes the released `@chatwoot/utils@0.0.57`, which adds text-header parameter construction and completeness validation. ### How to reproduce 1. Open a WhatsApp template containing text header `Welcome {{1}}` and body variables `{{1}}` and `{{2}}`. 2. Observe that the current composer displays only two body inputs and omits the header input. ### How to test 1. Open the same template in the conversation composer. 2. Confirm one header input and two body inputs are displayed. 3. Fill the values and confirm both the header and body previews update. 4. Send the template and confirm `processed_params` contains `header.1`, `body.1`, and `body.2`. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |
||
|
|
502c45f73b |
fix: freeze SLA misses after resolution (#15024)
## Description Resolved conversations now preserve historical SLA misses without allowing their displayed duration to keep growing. Applied SLAs record a stable completion timestamp that is shared through REST and realtime payloads, and the dashboard freezes FRT, NRT, and RT misses at that point. Legacy completed SLAs without a reliable timestamp remain visible as a static missed state. Terminal SLAs remain frozen when a conversation is reopened; a reopen before finalization continues the same SLA without resetting its deadlines. ### Closes [CW-7597](https://linear.app/chatwoot/issue/CW-7597/freeze-sla-miss-durations-after-conversation-resolution) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How to reproduce 1. Apply an SLA with a resolution-time threshold to a conversation. 2. Let the threshold breach, then resolve the conversation. 3. Observe that the recorded miss duration continues increasing every minute even though the conversation is resolved. ## What changed - Added nullable `applied_slas.completed_at` and exposed it as `sla_completed_at` in conversation, report, and websocket payloads. - Captured completion before broadcasting resolution and preserved it for terminal applied SLAs. - Frozen recorded FRT, NRT, and RT durations in classic and next-generation conversation labels, including a static fallback for legacy rows. - Added a dry-run-first, resumable Rails runner for account-scoped or explicitly global historical repair without enqueuing jobs or touching `updated_at`. Account-scoped production rollout starts with: ```sh ACCOUNT_ID=168154 bundle exec rails runner script/backfill_applied_sla_completed_at.rb ACCOUNT_ID=168154 APPLY=true bundle exec rails runner script/backfill_applied_sla_completed_at.rb ``` ## How Has This Been Tested? - Verified resolution stamping, nonterminal reopen clearing, and terminal reopen preservation. - Verified dry-run, apply, account/global scope, resume, skip, idempotency, and timestamp-preserving backfill behavior. - Verified all three miss types freeze and existing conversation-card behavior remains intact. - 71 focused RSpec examples and 37 focused Vitest examples pass. - RuboCop, ESLint, and diff checks pass. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> |
||
|
|
651db39765 |
fix: harden article author updates (#15229)
## Description Article edits now ignore an `author_id` that does not belong to the current account, retain the existing author, and still apply other valid article changes. Article creation continues to reject cross-account authors with a generic validation error and without creating a record. The authenticated article serializers still omit authors without a current-account membership so existing forged or stale records cannot expose agent profile fields. The guard now checks `current_account_user` directly to make that intent explicit. ## Closes Follow-up to [CW-7665](https://linear.app/chatwoot/issue/CW-7665) and [#15191](https://github.com/chatwoot/chatwoot/pull/15191). ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Creating an article with a cross-account author returns `422` and does not create an article. - Updating an article with a cross-account author retains the previous author while applying other valid attributes. - Articles whose previous author is no longer an account member can still be edited without exposing that author's agent profile. - Existing OSS and Enterprise article request coverage passes locally. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com> |
||
|
|
bc9839ed38 |
fix: stabilize conversation FAQ lock spec (#15236)
## Description Backend CI no longer flakes when the conversation FAQ grouping-lock spec runs after Rails has reloaded application constants. The job already raises the intended lock-acquisition error and prevents concurrent suggestion generation. This updates the assertion to compare the error class name, preserving that behavior without depending on a reload-sensitive Ruby `Class` object. The failure was reproduced in [CircleCI backend job 171299](https://app.circleci.com/pipelines/github/chatwoot/chatwoot/116560/workflows/efcee2f1-5af8-4121-82c3-920002dc420b/jobs/171299). ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? The isolated job spec passes, the exact 18-way CircleCI shard selection passes all 442 examples, and the changed spec passes Ruby lint. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] New and existing unit tests pass locally with my changes |