Remove Community Chatwoot Hub URL/push relay/sync/registration/event/changelog
egress. changelog.js becomes a local empty-feed adapter (no axios/fetch/network)
exporting the default ChangelogApi; links.js drops the Hub changelog URL.
lib/chatwoot_hub.rb removes base_url/push_notification_url/billing_base_url/
instance_config/send_push/send_push_with_response; billing_url reads only an
explicit CHATWOOT_BILLING_URL env, HTTPS-only with host and no userinfo, and
never falls back to a Hub URL. Enterprise proprietary base_url override is
preserved (spec uses singleton_class.instance_methods(false) for edition-safety).
privacy_audit uses a narrow per-file/per-rule Enterprise exception (hub-url
only) and privacy_audit_test.sh proves forbidden Enterprise runtime lines are
still detected; deployment privacy guard unchanged. Approved by independent
five-key review deleg_49d6ee2e (passed=true, blocking arrays empty).
In-process, thread-safe last-request-summary sink (per account_id) with a
public last_request_summary(account_id:) reader so FilteredCountInstrumentation's
documented local observability signals are consumable instead of discarded.
No APM/New Relic or any off-box egress; sink and reader stay fully in-process.
Adds regression specs asserting the completed summary (status, counts,
duration) is readable and an unknown account returns nil.
This PR adds outcome-based reporting builders for the redesigned Captain
overview, resolution flow, and resolution trend. These builders are not
wired to controllers or the frontend yet, so the existing Captain
metrics remain unchanged.
## AssistantOverviewStatsBuilder
Builds current and previous reporting-window metrics, including the
comparison trend, for the overview and CSAT cards.
| Statistic | Description |
| --- | --- |
| Conversations handled | Counts outcome episodes in which Captain
replied or performed a non-usage-limit handoff. |
| Auto-resolution rate | Shows autonomous resolutions as a percentage of
conversations handled. |
| Autonomous resolutions | Counts episodes resolved by Captain without a
handoff or an earlier human reply. |
| Handoff rate | Shows involved handoffs as a percentage of
conversations handled. |
| Handoff count | Counts involved handoffs while excluding demand
blocked by usage limits. |
| Hours saved | Estimates displaced agent effort from Captain's public
replies at two minutes per reply. |
| Reopen rate | Shows the share of autonomous resolutions followed by
another episode. |
| Conversation depth | Shows the average number of public Captain
replies per replied-to conversation. |
| Durable resolution rate | Shows autonomous resolutions that remained
closed for at least seven days among resolutions old enough to assess. |
| Autonomous CSAT score | Averages CSAT ratings from conversations
resolved autonomously by Captain. |
| Assisted CSAT score | Averages CSAT ratings from resolved
conversations where Captain participated alongside a human. |
| Human-only CSAT score | Averages account CSAT from conversations where
Captain never participated. |
| Median resolution time | Reports the median elapsed seconds from
demand start to resolution for handled episodes. |
## AssistantResolutionFlowBuilder
Builds the current-window Sankey data and a matching handoff-reason
distribution from the same outcome cohort.
| Statistic | Description |
| --- | --- |
| Conversations handled | Provides the Sankey entry count for episodes
where Captain participated. |
| Resolved by Captain | Counts handled episodes resolved autonomously by
Captain. |
| Handed off | Counts handled episodes transferred to a human for a
non-usage-limit reason. |
| Closed with team | Counts handled episodes outside the
autonomous-resolution and handoff branches. |
| Reopened within seven days | Counts Captain resolutions followed by a
new episode before the seven-day durability boundary. |
| Stayed closed | Counts Captain resolutions with no reopen inside seven
days. |
| Handoff reason nodes | Shows the two largest handoff categories and
combines the remainder as other reasons. |
| Handoff distribution | Returns every involved handoff category with
its count and percentage, including unclassified handoffs. |
## AssistantResolutionTrendStatsBuilder
Builds a zero-filled, timezone-aware resolution series in one outcome
query, using daily buckets for windows of 15 days or less and weekly
buckets for longer windows.
| Statistic | Description |
| --- | --- |
| Granularity | Identifies whether the response contains daily or weekly
buckets. |
| Bucket range | Returns the start and end date represented by each
bucket. |
| Conversations handled | Counts Captain-involved outcome episodes whose
demand started in each bucket. |
| Resolved by Captain | Counts autonomously resolved outcome episodes
whose demand started in each bucket. |
## Description
Contact export builds its CSV with the standard library `CSV`, which
writes user-supplied values (name, email, phone, custom attributes)
verbatim. When a cell begins with a formula character (`=`, `+`, `-`,
`@`, and tab/CR), spreadsheet applications interpret it as a formula on
open. This switches the export to `CSVSafe` (the `csv-safe` gem already
used by the v2 report exports), which prefixes such fields so they are
treated as text.
No new dependency, no behavioural change beyond neutralising
formula-leading cells. Note: phone numbers stored with a leading `+` are
now prefixed with a single quote in the exported file, consistent with
how the report exports already behave.
Ref https://linear.app/chatwoot/issue/CW-7473
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
`bundle exec rspec spec/jobs/account/contacts_export_job_spec.rb` —
added a case asserting a formula-leading contact value is neutralised on
export; existing cases updated for the phone-number prefix.
## 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
## Summary
Adds conversation usage to Captain documents and user created FAQs. Only
knowledge used in a Captain answer sent to the contact is counted.
Lookups that end in a handoff are excluded.
Administrators can see how many distinct conversations used a knowledge
record. Document usage appears in the Usage tab inside document details.
User created FAQ usage appears on each FAQ card and opens in a
conversation panel.
Deleted conversations are excluded from counts, sorting, and
conversation lists.
Usage shown in side panel
<img width="2342" height="1502" alt="CleanShot 2026-08-12 at 18 29
02@2x"
src="https://github.com/user-attachments/assets/67301051-6df5-4eb6-9b8d-e1fc75bb02f2"
/>
Sorting options
<img width="580" height="241" alt="image"
src="https://github.com/user-attachments/assets/078566b7-481b-491b-b484-c2fef481f1b1"
/>
## Access
Conversation usage is available only to administrators. Agents can still
view documents and FAQs, but they cannot see usage counts, the Usage
tab, the "Most used" sort, or conversation usage details.
The API applies the same rule. An agent request for `sort=most_used` is
rejected.
## Performance and pagination
Document and FAQ lists return 25 records per page. Usage counts are
calculated only for the records in each page.
Conversation usage panels load 25 conversations at a time and show a
"Load more" action when more conversations are available. The generated
FAQs tab also keeps its existing 25 item pagination.
The count queries use the JSON indexes on `document_ids` and
`used_faq_ids`. The "Most used" sort aggregates one assistant's sessions
once before it sorts and returns the requested document page.
## How to test
1. Sign in as an administrator and open the Captain documents page for
an assistant with tracked document usage.
2. Open a document and confirm that the Usage tab shows the distinct
conversation count and the matching conversations.
3. Select "Most used" and confirm that documents are ordered by distinct
conversation usage.
4. Open the user created FAQs page and confirm that FAQ cards show their
usage count and open the matching conversations.
5. Confirm that usage panels show 25 conversations first and can load
the next page.
6. Trigger a knowledge lookup that ends in a handoff and confirm that it
does not increase document or FAQ usage.
7. Sign in as an agent and confirm that usage counts, usage details, and
the "Most used" sort are not available.
## Closes
[CW-7498](https://linear.app/chatwoot/issue/CW-7498/fe)
Message pagination now constrains client-provided cursors to the
PostgreSQL integer range used by `messages.id`, preventing oversized
values from raising database errors while preserving before/after
pagination semantics.
## Closes
-
[CW-7922](https://linear.app/chatwoot/issue/CW-7922/harden-backend-paths-causing-production-sentry-errors)
- [Sentry 7663397140](https://chatwoot-p3.sentry.io/issues/7663397140/)
- [Sentry 7663397546](https://chatwoot-p3.sentry.io/issues/7663397546/)
- [Sentry 7663464772](https://chatwoot-p3.sentry.io/issues/7663464772/)
## How to reproduce
Request conversation messages with an extremely large `before` or
`after` cursor, such as `4611686018427387903`. PostgreSQL previously
rejected the value as outside the range for the integer `messages.id`
column.
## What changed
- Normalize message cursors before they reach the query.
- Clamp them to the valid signed 32-bit integer ID range.
- Cover oversized `before` and `after` cursors with finder specs.
Contact and conversation filters now reject malformed numeric/date
custom-attribute values and dangling query operators during validation,
returning the existing invalid-filter response instead of failing later
while building SQL.
## Closes
-
[CW-7922](https://linear.app/chatwoot/issue/CW-7922/harden-backend-paths-causing-production-sentry-errors)
- [Sentry 7663920842](https://chatwoot-p3.sentry.io/issues/7663920842/)
- [Sentry 7663378104](https://chatwoot-p3.sentry.io/issues/7663378104/)
## How to reproduce
Submit a custom numeric attribute filter with an empty value, or end a
filter payload with a nonblank `query_operator`. These inputs previously
reached coercion/query construction and raised production exceptions.
## What changed
- Validate numeric and date custom-attribute values before query
construction.
- Preserve presence operators, which do not require a comparison value.
- Reject a query operator on the final filter condition.
- Add contact and conversation service regression coverage.
Round-robin queue resets now leave the Redis queue empty when an inbox
has no members, avoiding an invalid sorted-set write while keeping
normal queue population unchanged.
## Closes
-
[CW-7922](https://linear.app/chatwoot/issue/CW-7922/harden-backend-paths-causing-production-sentry-errors)
- [Sentry 7663380084](https://chatwoot-p3.sentry.io/issues/7663380084/)
## How to reproduce
Run round-robin assignment for an inbox with no inbox members. Resetting
the queue previously attempted a Redis sorted-set write without any
member/score pairs.
## What changed
- Clear the existing queue as before.
- Skip queue population when there are no eligible user IDs.
- Add service coverage for an inbox with no members.
## Description
The CSAT survey response CSV export builds rows with the standard
library `CSV`, writing values verbatim, including the feedback message,
which is free text submitted by end users. When a cell begins with a
formula character (`=`, `+`, `-`, `@`, and tab/CR), spreadsheet
applications interpret it as a formula on open. This switches the three
`CSV.generate_line` calls in the export template to
`CSVSafe.generate_line` (the `csv-safe` gem already used by the v2
report exports), which prefixes such fields so they are treated as text.
No new dependency, no behavioural change beyond neutralising
formula-leading cells.
Ref https://linear.app/chatwoot/issue/CW-7473
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
`bundle exec rspec
spec/controllers/api/v1/accounts/csat_survey_responses_controller_spec.rb`
— added a case asserting a formula-leading feedback value is neutralised
in the downloaded CSV.
## 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
Conversation completion evaluations now use a dedicated internal LLM
feature with GPT 4.1 as the default. The internal route keeps the
completion model separate from the installation wide Captain model
override and from the assistant route, which can use GPT 5.2 for Captain
V2 accounts. Evaluations continue to use the installation API key and do
not consume Captain response credits.
## What changed
Added an internal `conversation_completion` feature to the LLM model
config and excluded internal features from account preferences, the
Captain settings API, and Super Admin model overrides.
Updated `Captain::ConversationCompletionService` to resolve its model
through `Llm::FeatureRouter`.
Added focused service and request coverage for model routing and
settings visibility.
## Description
This isn't tied to an open issue — I found it by extrapolating from the
bug class fixed in #15415 ("fix: anchor contact phone number
validation"), which fixed a `Contact#phone_number` format validation
that was missing a leading `\A` anchor. That made me audit every
hand-written regex-based validation in the codebase for the same class
of anchoring mistake (`format: { with: ... }` validators, plus
`match?`/`=~` calls used for validation-style checks, across `app/`,
`enterprise/`, and `lib/`).
Everything else was already correctly anchored. One real instance of the
*sibling* mistake remains:
`RegexHelper::UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE` (used only by
`Label#title`'s format validation) is
`/\A[\p{L}\p{N}]+[\p{L}\p{N}_-]+\Z/` — note `\Z` (capital), not `\z`.
Unlike `\z`, `\Z` also matches just before a single trailing `"\n"` at
the end of the string. The surrounding comment documents the intended
character set (unicode letters/numbers/underscore/hyphen, not starting
with `_`/`-`) and says nothing about tolerating a trailing newline, so
this reads as an unintentional choice of anchor rather than a deliberate
one.
Concretely: `Label.new(title: "hello_world\n").valid?` returns `true` on
current `develop` and persists a title with a literal trailing newline,
because `\Z` lets the `\n` slip through. `Label` only lowercases the
title before validating (no `strip`), so nothing else catches this.
This is a narrower/lower-severity variant of the #15415 bug (it only
ever admits one specific trailing character, not an arbitrary
prefix/suffix), but it's the same underlying mistake, independently
verified against current source, not just pattern-matched from the diff.
## What changed
- `lib/regex_helper.rb`: `UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE`
now ends in `\z` instead of `\Z`, with a comment explaining why.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Ran against a real local Rails env (Ruby 3.4.4, PostgreSQL 16, Redis):
`bundle exec rspec spec/models/label_spec.rb`
Added a regression test asserting `Label.new(title: "hello_world\n")` is
invalid. Confirmed it fails against the pre-fix `\Z` regex and passes
after switching to `\z`. All existing `label_spec.rb` examples
(including the existing "foreign characters", "special characters", and
"uppercase" title-validation cases) continue to pass unchanged.
## 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
- [x] New and existing unit tests pass locally with my changes
🤖 This fix was authored by an AI coding agent (Claude) working on behalf
of Mithtech, an ERPNext/Frappe/Medusa.js implementation studio, as part
of a deliberate effort to build a track record of verified upstream
open-source contributions. Flagging this transparently per common
courtesy — happy to answer any questions about the change, including how
it was found (auditing for the same regex-anchor mistake class as
#15415).
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
## Description
After a successful super admin login, redirect to the super admin
dashboard (`super_admin_root_path`) instead of the users list. The
dashboard is a lighter, overview-first landing page, while the users
index does an exact count over the full users table on every load and
can be slow on large instances.
This only changes the post-login landing page. The users list is still
reachable from the navigation.
Fixes https://linear.app/chatwoot/issue/CW-7928
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Added request specs for `SuperAdmin::Devise::SessionsController#create`:
- successful login redirects to `super_admin_root_path`
- invalid credentials redirect back to the login page
`bundle exec rspec
spec/controllers/super_admin/devise/sessions_controller_spec.rb` -> 3
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] 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
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>
## 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
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.
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)
## 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>
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.
## 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>
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.
## 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
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>
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>
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.
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.
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>
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.
## 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>
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>
## 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
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>
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>
## 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
## 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
## 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
## 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
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.
## 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>
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.