Skip to content

chore: upgrade to Chatwoot v4.18.0 - #2

Merged
neilLasrado merged 795 commits into
masterfrom
switchon/upgrade-4.18.0
Sep 24, 2026
Merged

neilLasrado merged 795 commits into
masterfrom
switchon/upgrade-4.18.0

Conversation

@neilLasrado

Copy link
Copy Markdown
Member

Brings our fork up from Chatwoot v4.13.0 to upstream v4.18.0 (793 upstream commits).

  • The merge was clean, with no conflicts.
  • All 5 SwitchOn customizations are kept: logos, login page text, survey text, dashboard reorder, and socket disconnection handling in sdk.js.
  • The previous master is tagged pre-4.18-backup (1a21383bef) for rollback.

Deploy notes

  • 59 new migrations: back up the DB before running db:migrate.
  • Frontend assets need a rebuild (assets:precompile).

🤖 Generated with Claude Code

tds-1 and others added 30 commits August 6, 2026 12:26
…chatwoot#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>
Data imports created without a name now receive a readable default such
as `Contacts - 2026-08-06`. This prevents legacy CSV contact imports
from appearing as `Untitled import` under Settings → Data while
preserving names supplied by users or other import providers.

### Closes

-
[CW-7877](https://linear.app/chatwoot/issue/CW-7877/add-default-names-for-unnamed-data-imports)

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

1. Create a contact CSV import without supplying a name.
2. Open Settings → Data and confirm its name follows `Contacts -
YYYY-MM-DD`.
3. Create an import with an explicit name and confirm that name remains
unchanged.

## 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
- [ ] 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

**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>
…woot#15326)

When an agent has multiple tabs or windows open on the same account, an
inbound WhatsApp call rings in all of them, as expected. But once the
call is answered in one tab, the others never found out — they kept
showing the incoming-call popup and playing the ringtone indefinitely,
as if the call were still waiting to be picked up.

## How to reproduce
1. Log into the same agent account in two browser tabs.
2. Receive an inbound WhatsApp call (rings in both tabs).
3. Accept the call in one tab.
4. The other tab keeps ringing and shows the call as still incoming.

## What changed
- The backend already broadcasts a `voice_call.accepted` event
account-wide when a call is answered, but the dashboard never had a
listener registered for it — the event was silently dropped. Added the
missing handler in `actionCable.js` so every tab except the one that
owns the now-active call clears its ringing state.
- Added `root: true` to `.eslintrc.js` so ESLint config resolution stops
at the project root instead of also picking up a parent directory's
config (this repo's git-worktree layout nests worktrees under the main
checkout, which was causing an ambiguous plugin-resolution error for
anyone linting from a worktree).

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
…hatwoot#14103)

## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-6884/nudge-users-with-a-dashboard-banner-when-2fa-backup-codes-run-low

## Description

Shows a dashboard-wide banner when the signed-in user has 3 or fewer
unused backup codes left (amber), turning to an alert style at 0
remaining. Clicking "Generate codes" takes the user to the MFA settings
page so they can regenerate codes before they get locked out. Inspired
by Google's post-backup-code-use nudges.

## How to test

1. Sign in as a user with MFA enabled.
<img width="1512" height="824" alt="Screenshot 2026-08-05 at 4 52 15 PM"
src="https://github.com/user-attachments/assets/08138f3e-cc15-451e-bbcc-7772dc2a875c"
/>
<img width="1507" height="701" alt="Screenshot 2026-08-05 at 4 54 02 PM"
src="https://github.com/user-attachments/assets/580051fa-cbb5-47c1-81a3-258b7c5b5a03"
/>


2. In a Rails console, simulate a low state by marking most backup codes
as used:
   ```ruby
   u = User.find_by(email: '<your user>')
   codes = u.otp_backup_codes.dup
   (0...8).each { |i| codes[i] = 'XXXXXXXX' }
   u.otp_backup_codes = codes
   u.save!
   ```
3. Reload any dashboard page — the amber banner should appear with a
"Generate codes" CTA.
4. Click the CTA — it should route to **Profile → Two-Factor
Authentication**, where you can regenerate codes.
5. Set the count to 0 (mark all 10 as `'XXXXXXXX'`) — banner should
switch to the red/alert style.
6. Regenerate codes — banner should disappear on the next dashboard page
load.



## 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


> Base is the [disable-with-backup-code PR
branch](chatwoot#14102) so CTAs around
recovery are consistent; rebase onto `develop` once that merges.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Data Imports is now part of the shared paid-plan entitlement set.
Startups, Business, and Enterprise accounts receive the feature through
billing reconciliation, while Hacker/default accounts remain gated and
the existing API/UI feature checks stay unchanged.

### Closes

-
[CW-7878](https://linear.app/chatwoot/issue/CW-7878/enable-data-imports-for-all-paid-cloud-plans)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

1. Reconcile a Hacker account and confirm Data Imports remains disabled.
2. Reconcile Startups, Business, and Enterprise accounts and confirm
Data Imports is enabled for each paid tier.
3. Exercise the Stripe subscription update path and confirm the same
plan hierarchy is applied.

## Rollout

Existing paid accounts need a one-time reconciliation after deployment.
Run the following in the Rails console:

```rb
paid_plan_names = InstallationConfig.find_by!(name: 'CHATWOOT_CLOUD_PLANS').value.drop(1).pluck('name')
paid_accounts = Account.where("custom_attributes ->> 'plan_name' IN (?)", paid_plan_names)

puts "Reconciling #{paid_accounts.count} paid accounts"

paid_accounts.find_each do |account|
  Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
end
```

Future plan changes and subscription renewals are handled by the normal
Stripe reconciliation path.

## 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
- [ ] 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
…chatwoot#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.
…twoot#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>
…hatwoot#15356)

WhatsApp inbox responses now return an empty template list when the
stored `message_templates` value is not an array. This prevents mobile
clients from receiving the legacy/default object shape that can crash
the WhatsApp reply box.

Valid template arrays remain unchanged.

Related: chatwoot/chatwoot-mobile-app#1127

### Things to know

- No database migration or backfill is included.
- This does not change the web template-button behavior; that can be
handled separately.

### How to reproduce

1. In a local environment, set a WhatsApp inbox's `message_templates`
value to `{}`.
2. Load the inbox API response and open one of its conversations in the
mobile app.
3. Confirm the response contains `message_templates: []` and the reply
box does not crash.
4. Repeat with a valid template array and confirm the templates are
preserved.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
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.
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.
## What changed

This pull request adds three focused log emitters for Captain V2
response jobs:

* `job_dequeued` when Sidekiq fetches the job from Redis
* `job_skipped` when the conversation is not Pending at the first job
guard
* `response_discarded` when a newer customer message exists before model
generation

The dequeue middleware identifies V2 jobs by the triggering message ID
in the third serialized Active Job argument. It does not log Captain V1
or other Sidekiq jobs.

## Why

Recent production incidents have an enqueue record but no later job
record. Active Job `Performing` and `Performed` logs are now available
temporarily, but they do not show whether Sidekiq fetched a job before a
worker disappeared.

The dequeue log closes that gap. The two application logs explain the
early exits that otherwise produce no model trace.

The production root cause remains unresolved. This pull request adds
evidence for the next occurrence and does not change Captain response
behavior.

## Log volume

A Captain V2 response job adds one dequeue line. The other two lines
occur only on an early status skip or a pre-generation burst discard.
Existing Active Job, Langfuse, completion, failure, handoff, and usage
logs cover later stages.

## Validation

* Ruby syntax checks passed for the four implementation files.
* RuboCop found no offenses in the four implementation files.
* No new specs were added because this is temporary diagnostic logging
with no response behavior change.
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>
Improves the WhatsApp template listing so account admins can understand
template state and refresh templates from one place. Templates that have
not entered the WhatsApp approval flow now use clearer wording, the
latest sync attempt appears once at page level, and templates can be
filtered by content type.

The page can now start a template sync across all active WhatsApp
inboxes in the account and reports complete or partial failures. The New
template action is removed for now; provider-side template management
remains unchanged.

### Related

Related: chatwoot#15312

### How to test

1. Open **Settings → Templates** in an account with WhatsApp templates.
2. Confirm the latest sync attempt appears below the page description
and is no longer repeated on every template.
3. Confirm an unsubmitted template displays **Not submitted for WhatsApp
approval**.
4. Filter templates by content type and verify the list updates.
5. Click **Sync templates** and confirm sync starts for the account's
active WhatsApp inboxes.
6. Confirm the **New template** button is not displayed.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
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 chatwoot#15299. The
frontend follows in chatwoot#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>
…twoot#15354)

# Pull Request Template

## Description

This PR reworks the canned response picker to make it easier to search,
browse, and preview canned responses before inserting them. Typing `/`
now opens a larger picker with its own search field and a preview pane.
Previously, only a few responses were visible at a time, there was no
way to preview the full content, and searching relied on typing into the
composer, which stopped working for multi-word queries.

Search is now handled entirely inside the picker, so the composer stays
untouched while searching. Results match both the canned response
shortcut and its content, and each result shows a snippet centered
around the matched text instead of always displaying the beginning of
the response. The preview renders the response exactly as it will be
inserted, with variables resolved against the current conversation and
formatting unsupported by the channel already stripped.

The picker is positioned relative to the current typing line and
teleported to `body`, so it is no longer clipped by the composer. It
behaves consistently across the reply editor, the New Conversation
composer, and narrower editors such as Contact Notes, where the preview
moves below the list instead of disappearing.

This also fixes a pre-existing bug where variables without a value were
removed from the inserted text instead of being left for the backend to
resolve. In the New Conversation composer, where no variables are
available, all `{{ }}` placeholders were previously being silently
removed.

Fixes
https://linear.app/chatwoot/issue/CW-7854/inconvenient-canned-response-picker-and-lack-of-personal-canned

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Screenshots
<img width="1135" height="576" alt="image"
src="https://github.com/user-attachments/assets/b5f27b94-eeb5-4ac6-b6d9-da7dfd8c2306"
/>
<img width="393" height="490" alt="image"
src="https://github.com/user-attachments/assets/779f88b1-4958-41f5-9f51-c2eb8db7e53c"
/>



### Steps

1. Open a conversation and type `/` in the reply editor.
2. Search using a multi-word phrase that appears within a canned
response. Verify the matching response appears with a snippet centered
around the matched text.
3. Navigate the results with the arrow keys or Tab and verify the
preview updates.
4. Press Enter or click a response to insert it, and press Escape to
close the picker.
5. Repeat in a narrow editor such as Contact Notes and verify the
preview pane moves below the list.
6. In the New Conversation composer, insert a canned response containing
variables and verify the `{{ }}` placeholders are kept rather than
removed.



## 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
- [ ] 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
Improves SAML user handling for users associated with multiple accounts.
Restricts cross-account invitations and skips provider updates for
multi-account users.
Aligns SAML authentication and provider reset behavior with these
eligibility rules.
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Facebook inbox creation failures in Settings could leave users on the
same screen without explaining what went wrong. This change preserves
the backend error response and displays it through the existing toast
mechanism, making account-limit and other creation failures visible and
actionable.

The shared action now passes the original API error through, so the
newer onboarding flow also receives the specific backend message.
Successful Facebook inbox creation remains unchanged.

### How to reproduce

1. Use an account that has reached its inbox limit.
2. Go to Settings → Inboxes → Add Inbox → Facebook.
3. Select a Facebook Page and create the inbox.
4. Previously, the loading state ended without any visible error.

### How to test

1. Attempt Facebook inbox creation while the backend returns an HTTP 402
account-limit error.
2. Confirm the returned error appears as a toast message.
3. Retry after increasing the account limit and confirm inbox creation
continues normally.

### Things to know

The toast falls back to a generic localized creation error when the API
response does not contain a displayable message.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
…ntact's email (chatwoot#15373)

Automations that send an email transcript now let you insert the
conversation contact's email directly into the recipient field, instead
of only accepting a hardcoded address typed in by hand.

## Closes

https://linear.app/chatwoot/issue/CW-7535/allow-automation-to-send-conversation-transcripts-to-user-emails

## How to test
1. Go to **Settings → Automation → Create Automation** (or edit an
existing rule).
2. Add the **Send an Email Transcript** action.
3. Click **Use contact's email** next to the recipient field — it
inserts `{{contact.email}}` into the field, appending to any address
already typed rather than replacing it.
4. Save the rule and trigger it on a conversation with a contact that
has an email address; confirm the transcript is delivered to that
contact.

## What changed
- `AutomationActionInput.vue`: added a "Use contact's email"
quick-insert action next to the recipient field for the
`send_email_transcript` action, and switched the input from
`type="email"` to `type="text"` so the dynamic token isn't fought by
native email-format validation.
- No backend changes were needed — `send_email_transcript` already
resolves `{{contact.email}}` via the existing Liquid templating support
in `ActionService#send_email_transcript`; this change only exposes that
capability in the UI.
- This also benefits the Macros editor, which shares the same input
component.

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
## 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
…nts [CW-7792] (chatwoot#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 chatwoot#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.
MFA-authenticated users now remain signed in across browser restarts,
matching the session lifetime of password-authenticated users.

## Closes

-
[CW-7898](https://linear.app/chatwoot/issue/CW-7898/mfa-users-are-logged-out-when-the-browser-session-ends)

## How to reproduce

1. Enable MFA for a user.
2. Sign in and complete OTP or backup-code verification.
3. End the browser session, then reopen the browser and return to
Chatwoot.
4. Before this change, the user is sent back to the login page.

## Root cause and evidence

Before this change, successful MFA verification wrote
`cw_d_session_info` directly through `document.cookie` without an
`Expires` or `Max-Age` attribute. This makes it a session cookie:

- [MFA cookie creation on the base
commit](https://github.com/chatwoot/chatwoot/blob/a4eae9710ab49e7dc98d64a7e713fd1f77c451f8/app/javascript/dashboard/components/auth/MfaVerification.vue#L70-L87)
- [MDN session-cookie
behavior](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie)
- [Chromium cookie persistence
documentation](https://chromium.googlesource.com/chromium/src/+/master/net/cookies/README.md)

Password login already uses `setAuthCredentials`, which derives a
persistent cookie expiry from the authentication response:

- [Existing credential
helper](https://github.com/chatwoot/chatwoot/blob/a4eae9710ab49e7dc98d64a7e713fd1f77c451f8/app/javascript/dashboard/store/utils/api.js#L28-L36)
- [Configured two-month token
lifetime](https://github.com/chatwoot/chatwoot/blob/a4eae9710ab49e7dc98d64a7e713fd1f77c451f8/config/initializers/devise_token_auth.rb#L8-L10)

This change routes successful MFA verification through the same
credential helper and existing verified event. The regression test
asserts that MFA credentials use this shared persistence path.

Session-cookie restoration is browser and profile dependent. Chromium
may persist session cookies when restoring a previous browsing session,
so the problem can be masked on some restarts. This PR does not rely on
a desktop-versus-mobile distinction.

## How to verify

1. On `develop`, complete an MFA login.
2. Inspect `cw_d_session_info` in browser developer tools. Its expiry is
shown as `Session`.
3. Repeat on this branch.
4. Confirm that `cw_d_session_info` has a concrete expiry derived from
the authentication response instead of `Session`.
## Description

This systemd unit is installed on every self-hosted Linux install
(copied by `deployment/setup_18.04.sh` / `setup_20.04.sh`) as well as
our own worker hosts, so the memory cap has to be correct across a wide
range of box sizes.

It is currently a fixed `MemoryMax=1.2G`, chosen when workers ran on 2GB
hosts (chatwoot#12915). On larger hosts that fixed cap sits at a high fraction
of the limit under normal load, so transient spikes hit the hard limit
and the worker is OOM-restarted more often than necessary (each restart
drops the jobs in-flight at that instant).

Switching to `MemoryMax=60%` makes the cap scale with the host's
physical memory instead of being fixed:

- 2GB host: 60% = 1.2G, identical to today (no regression for small
installs)
- 4GB host: 60% = ~2.4G, room to absorb spikes without a premature
restart
- larger hosts scale proportionally

`MemoryHigh=infinity` is unchanged, so the worker is still never
throttled by cgroup reclaim (kept intentionally since chatwoot#12871 to avoid
reclaim-induced worker stalls). Only the hard cap changes, and only its
scaling.

Fixes https://linear.app/chatwoot/issue/INF-100

## Type of change

- [x] Performance (non-breaking change which improves performance)
scmmishra and others added 25 commits September 15, 2026 15:44
…t#15808)

Fixes advanced-filter totals reverting to open-conversation counts when
requests finish out of order. Count updates now follow request order
within the active view, preserving newer event refreshes and allowing
useful metadata updates when a list refresh fails.

Adds regression coverage for request ordering, failures, pagination, and
cached assignee-tab switches.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
chatwoot#15826)

Temporarily hide the WhatsApp Request Contact Info action in
conversation responses to remove message-history lookups from routine
inbox browsing. Conversation payloads keep the existing fields but
return `available: false`, `reason: null`, and `delivery_mode: null`.

This is an alternative to chatwoot#15825. Instead of displaying an action that
can later fail because of a pending request in older history, this
mitigation hides the dedicated action entirely while a cheaper
pending-state approach is considered.

## What changed

- Return disabled contact-info availability from the shared conversation
partial without invoking eligibility checks.
- Disconnect the batch lookup from list rendering while retaining the
service code for re-enabling the feature.
- Preserve validation for direct API requests and manually selected
contact-info templates. This disables the dedicated UI action, not the
backend capability.

## How to reproduce

Open a WhatsApp Cloud conversation for a BSUID contact without a phone
number. Before this change, conversation rendering checks message
history to determine whether to show or disable Request Contact Info.
After this change, the dedicated action is hidden and rendering no
longer runs that lookup.

Check conversation lists, filtered lists, contact conversation lists,
and individual conversation responses. Each should return the disabled
contact-info payload.
chatwoot#15827)

The Contacts page could fail to load on larger accounts. When a user has
never picked a sort, the list asked for contacts ordered by **oldest**
activity first. No index can serve that order, so every page load read
every contact in the account just to return 15. When those rows aren't
cached, or the database disk is under load, that runs past the 14s
statement timeout and the page never loads. The page now defaults to
**most recent** activity first. The existing `(account_id,
last_activity_at DESC NULLS LAST)` index serves that order, so a page
load reads about 20 pages whatever the account's size. Users who picked
a sort keep it.

## Closes

- No existing issue. Found via recurring `PG::QueryCanceled` /
`Rack::Timeout` errors on `contacts#index`.

## How to reproduce

1. On a large account, open **Contacts** as a user who has never changed
the sort.
2. The page sends `GET
/api/v1/accounts/:id/contacts?sort=last_activity_at`, which becomes
`ORDER BY last_activity_at ASC NULLS LAST LIMIT 15`.
3. Postgres fetches and sorts every listed contact of the account. When
those pages come from disk, the request hits the statement timeout.

## What changed

- `ContactsIndex.vue`: when no `contacts_sort_by` UI setting is saved,
the default sort is now `-last_activity_at` instead of
`last_activity_at`.
- Measured on production for a ~57k-contact account:
- old default: 57,768 pages read per load (56 ms cached, about 24s from
disk at the measured ~0.42 ms per page);
  - new default: an index scan that stops after 15 rows.
- Contact search uses the same plan for either sort direction (trigram
bitmap scan, then a sort of the matches), so it is unaffected.
- Known trade-off: on accounts made up mostly of anonymous visitors, the
new order walks more of the activity index before finding 15 listed
contacts. A partial index restricted to listed contacts removes that
cost if it shows up.

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
…ot#15752)

# Pull Request Template

## Description

Sign-in and sign-out write one audit row per account the user belongs to
using `insert_all!`, which bypasses the `after_create_commit` that
enqueues the IP geolocation lookup added in chatwoot#15455. Session audits were
the only rows that never got a city or country.

Restoring the callback would undo chatwoot#15572, which took a 500-account
sign-in from 2016 queries to 519. Since every row from one sign-in
shares a `remote_address` and `request_uuid`, this enqueues a single job
per sign-in that resolves the address once and applies it to that
request's rows in one `update_all`, scoped to accounts with `ip_lookup`
enabled. Constant cost regardless of membership count.

The second commit fixes a defect found while testing. `audit_event_rows`
used `::Audited.store[:current_request_uuid] || SecureRandom.uuid`.
Rails strips an inbound `X-Request-Id` to an empty string when it holds
only punctuation, and `""` is truthy, so those rows were written with a
blank uuid and the new job would skip the batch. Now uses `.presence`.

Fixes https://linear.app/chatwoot/issue/CW-8135

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

New job spec: one lookup applied to every row of a request, isolation
from other requests, a batch spanning eligible and ineligible accounts,
no lookup for ineligible or empty scopes, error swallowing.

Session controller spec: exactly one job enqueued for a user in four
accounts, and rows still share a generated uuid when the request id
sanitises to blank. Verified that spec fails without the `.presence`
fix.

27 examples, 0 failures locally. Rubocop clean.

## 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] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
## Description

Agents can now use the existing conversation sort menu while viewing
conversations narrowed by advanced filters or saved folders. Changing
the order refetches the filtered result set before pagination, so
oldest/newest, activity, unread, priority, and waiting-time ordering
remain consistent across loaded pages.

## Closes

-
[CW-7862](https://linear.app/chatwoot/issue/CW-7862/lack-of-sorting-options-when-using-filters-in-conversations-tab)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [x] This change requires a documentation update

## What changed

- Keep the existing order menu available for ad-hoc filtered and
saved-folder conversation lists while hiding the conflicting status
selector.
- Share the server-side sort whitelist between standard and filtered
conversation queries, preserving existing defaults and legacy aliases.
- Send the selected sort through filtered fetches, pagination, and
reconnect refreshes, with client-side ordering aligned to the server.
- Document the optional `sort_by` parameter for the conversation filter
API.

## How to test

1. Open Conversations and apply an advanced filter.
2. Open the sort menu and confirm the existing order options are
available while the status selector is hidden.
3. Change between oldest/newest, unread, priority, and waiting-time
options and confirm the filtered list refetches in the selected order.
4. Open a saved conversation folder and confirm the same sorting
behavior, including after loading another page.

## 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 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Incoming WhatsApp replies through Twilio lose their quoted-message
context, leaving agents unable to tell which earlier message the
customer means. This change displays the referenced message when it
exists in the same conversation.

The Twilio callback now accepts `OriginalRepliedMessageSid` and passes
it to the existing message reply resolver as `in_reply_to_external_id`.

### Things to know

- Applies to incoming quoted replies. Sending quoted replies through
Twilio remains unchanged.
- Uses the existing same-conversation lookup; references to unavailable
messages retain the existing unresolved-reply behavior.
- Previously received messages are not backfilled.

### How to reproduce

1. In a Twilio WhatsApp conversation, reply to an earlier message using
WhatsApp's reply action.
2. Before this fix, Chatwoot displays the reply without the quoted
message even when Twilio supplies its SID.

### How to test

1. Reply to a recent message in an existing Twilio WhatsApp
conversation.
2. Confirm the incoming reply displays the original message in its quote
preview.
3. Click the quote preview to navigate to the original message.

Locally verified by posting a mock Twilio webhook to the callback
endpoint: the incoming message persisted both reply identifiers and
displayed the original message in the conversation UI. Regression
coverage checks that the controller forwards the quoted SID and the
incoming-message service persists both reply identifiers. The focused
Twilio suite passed with 52 examples and no failures; RuboCop and diff
checks also passed.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
… is not in the store (chatwoot#15795)

Answering an incoming WhatsApp call from the call bubble in the
conversation could show "Internal Server Error", and the caller then saw
the call as missed. This happened when the call wasn't in the agent's
calls store: it rang while the agent wasn't online, or it was dismissed
earlier. In that case the dashboard fell back to the Twilio flow and
requested a Twilio token for the WhatsApp inbox.

## Closes

- https://linear.app/chatwoot/issue/CW-8174

## How to reproduce

1. Set your availability to Busy or Offline and receive a WhatsApp call
on an inbox with calling enabled.
2. Open the conversation and click Answer on the ringing call bubble.
3. Before: "Internal Server Error" and the call ends as missed. After:
the call connects.

## What changed

- The call bubble adds the call to the calls store from its own message
data before joining, so the WhatsApp answer path is used.
- `ConferenceController` only resolves Twilio inboxes, so the Twilio
token and conference endpoints return 404 for other inboxes instead of
500.
# Pull Request Template

## Description

This PR fixes sidebar navigation items not behaving like real links.
Middle-click, Cmd/Ctrl-click, and the browser’s “Open link in new tab”
action now work as expected.

Fixes
https://linear.app/chatwoot/issue/CW-7868/impossibility-to-open-navigation-menu-items-in-a-new-window-or-new-tab

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Screenshots

**Before**
<img width="369" height="387" alt="image"
src="https://github.com/user-attachments/assets/280b540a-1c4c-4f7c-b459-17ea8823fd67"
/>


**After**
<img width="369" height="387" alt="image"
src="https://github.com/user-attachments/assets/87a02578-7064-41ae-abd9-2f6bd4cb5a5e"
/>



## 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
- [ ] 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

Restore the dependency-audit CI check by adding a documented exception
for RubyLLM's CVE-2026-67991 on Chatwoot's supported runtime. The change
adds only this advisory to `.bundler-audit.yml`, with the applicability
rationale and removal condition. RubyLLM remains at 1.15.0 and every
other advisory remains subject to the existing audit configuration.

### Advisory verification and applicability

[CVE-2026-67991 /
GHSA-42r3-x6vx-x49x](https://github.com/rubysec/ruby-advisory-db/blob/44784c295391577f25d198a9205eae4ba73ec4da/gems/ruby_llm/CVE-2026-67991.yml)
describes polynomial-time name normalization on Ruby 3.1.x when a very
long crafted class, agent, or tool name reaches the affected operation.
The released 1.15.0 gem contains the regex inline in
`RubyLLM::Tool#name`; the advisory's later `Utils.underscore` reference
does not mean released gems were unaffected.

The exception is based on the application's runtime and actual input
paths:

- **Runtime:** the Gemfile, `.ruby-version`, CircleCI and Docker images
specify Ruby 3.4.4. A bounded local probe of the installed `Tool#name`
method reproduced quadratic behavior on Ruby 3.1.0, but linear behavior
on Ruby 3.4.4. The latter also reports this regex as linear-time,
consistent with [Ruby's regex memoization introduced in
3.2](https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/).
- **Built-in tools:** names come from fixed application classes, rather
than user-generated class names.
- **Custom tools:** [slugs are limited to 64
characters](https://github.com/chatwoot/chatwoot/blob/e16d8f2fa4bd00c65747de0b8363f0e05092e36c/enterprise/app/models/captain/custom_tool.rb#L38-L65).
[Toolable overrides the name
method](https://github.com/chatwoot/chatwoot/blob/e16d8f2fa4bd00c65747de0b8363f0e05092e36c/enterprise/app/models/concerns/toolable.rb#L8-L25)
to return that slug directly, bypassing RubyLLM's normalizer.
- **Agent handoffs:** Chatwoot uses `Agents::Agent` from ai-agents; its
handoff tools override naming with a separate lowercase/filter
conversion. Scenario handoff names also have a length budget. Ordinary
prompt/message text does not automatically enter the affected
normalization operation.

Local timings for synthetic all-capital class names:

| Characters | Ruby 3.1.0 | Ruby 3.4.4 |
| --- | ---: | ---: |
| 5,000 | 104 ms | 0.85 ms |
| 10,000 | 430 ms | 1.72 ms |
| 20,000 | 1,676 ms | 3.31 ms |

These measurements characterize the reported regex behavior, not
production capacity. Ruby 3.1.0 was used only for the isolated method
probe; Chatwoot itself was not run on that unsupported version. Live
deployment runtimes were not remotely inspected.

### Why this PR does not upgrade RubyLLM

As of 16 September 2026, stable 1.16.0 still contains the pattern and
still matches the advisory. The [upstream
fix](crmne/ruby_llm@9d75b03)
is released starting with 2.0.0.rc1; the latest release is 2.0.0.rc4,
also a prerelease.

`ai-agents 0.12.0` requires `ruby_llm ~> 1.14`. An isolated Bundler
resolution rejects 2.0.0.rc4. Bypassing the constraint for a
compatibility probe makes `require 'agents'` fail on the removed `param`
method. Chatwoot [requires agents unconditionally during
initialization](https://github.com/chatwoot/chatwoot/blob/e16d8f2fa4bd00c65747de0b8363f0e05092e36c/config/initializers/ai_agents.rb#L1-L3),
so simply widening that constraint can prevent application startup,
including when Captain is unused.

The [2.0
migration](https://github.com/crmne/ruby_llm/blob/v2.0.0.rc4/docs/_reference/upgrading.md)
also affects existing Chatwoot behavior:

| Surface | Required migration |
| --- | --- |
| Captain V2 / scenario handoffs | Replace removed `Tool::Halt`, update
instruction/tool replacement APIs, and preserve runner state and handoff
control. |
| Built-in/custom tools and Copilot | Update `param`/`params`,
parameter-schema readers, tool registration, provider options and
callbacks. |
| Rewrites, replies, summaries, labels and other Captain tasks | Replace
direct token readers; current response conversion can otherwise fail
after a successful provider response. |
| Images and conversation replay | Replace `RubyLLM::Content` with the
new attachment/message API. |
| Structured generation | Read parsed output through `response.parsed`;
current Hash consumers can otherwise produce empty overview points,
blank article fields or raw JSON taglines. |
| OpenAI-compatible endpoints | Make protocol selection explicit: 2.0
defaults to `/responses` instead of `/chat/completions`. Existing
`response_format` options and custom endpoints need adaptation or an
explicit Chat Completions protocol. |
| Request behavior / observability | Validate temperature settings,
token/cost tracing and the renamed model-registry refresh method. |

The isolated request probe confirmed the protocol change and that a
GPT-5.2 request configured with temperature 0.5 sends 1.0 under 1.15 and
0.5 under rc4. No live provider requests were made.

There is no `acts_as_chat`/`acts_as_message` persistence integration in
Chatwoot, so the upstream Rails data migration does not automatically
apply. Embedding/moderation readers used here still exist, and legacy
speech/PDF operations use the separate OpenAI client. RubyLLM remains
MIT licensed. A supported upgrade should be coordinated with ai-agents
instead of being folded into this audit exception.

### Removal condition

Remove the exception when Chatwoot adopts a supported patched
RubyLLM/ai-agents combination, including a recognized compatible 1.x
backport if one becomes available. Reassess it if the supported Ruby
runtime or tool-name construction changes. This records advisory
applicability; it does not claim that RubyLLM 1.15.0 includes the
upstream fix.

The original audit failure was reproduced against ruby-advisory-db
`44784c295391577f25d198a9205eae4ba73ec4da`. The actual changed
configuration now passes the refreshed audit, YAML formatting and
whitespace checks. The prior investigation passed 528 existing focused
specs on this application revision; after adding the exception, all 131
selected Captain base-task, custom-tool and scenario specs passed again.
Existing Rails enum deprecation warnings remain unchanged. No new specs
were added for the configuration-only change.

## Closes

No linked ticket.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How to reproduce

1. On the parent commit, run the CircleCI dependency-audit check with an
updated ruby-advisory-db. It reports RubyLLM 1.15.0 / CVE-2026-67991 and
exits unsuccessfully.
2. Run the same check on this branch. It succeeds with the documented
exception; the gem versions and application behavior remain unchanged.

## 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 made corresponding changes to the documentation (inline
exception rationale and removal condition)
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works (not applicable: audit configuration only)
- [x] New and existing unit tests pass locally with my changes (131
focused existing examples)
- [ ] Any dependent changes have been merged and published in downstream
modules (not applicable: no dependent changes)
Add reconnect attempts configuration for Redis.

## Description

This PR fixes an issue where Chatwoot stops delivering websocket updates
after Redis is restarted.

For the past few months, `needrestart` has been silently restarting
services that depend on upgraded libraries during system updates,
including Redis. This is the case for me, and maybe it will be for other
people as well.

Issue is When Redis restarts, Action Cable loses its Redis subscription
connection.

The root cause is that the Redis client used by Action Cable only
performs a limited reconnect attempt. If Redis is unavailable during
that window, the subscription remains disconnected permanently. As a
result, websocket connections stay open and continue sending pings, but
no real-time updates are delivered to clients.

This change configures Redis subscription reconnection attempts with
exponential backoff, allowing Action Cable to recover automatically from
temporary Redis outages and service restarts.

Closes chatwoot#14705

## Type of change

* [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Reproduction Steps

1. Deploy Chatwoot v4.8.0.
2. Open the Chatwoot admin panel.
3. Verify that real-time updates are working.
4. Restart Redis:
```sh
sudo systemctl restart redis-server

```


5. Send a new message or trigger a real-time event.

### Before this change

* Websocket connections remain open.
* Only ping frames are exchanged.
* No real-time updates are delivered.
* Chatwoot requires a restart to restore functionality.

### After this change

* Action Cable automatically reconnects to Redis.
* Real-time updates continue working after Redis becomes available.
* No Chatwoot restart is required.

## 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 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
* [x] Any dependent changes have been merged and published in downstream
modules

## Environment

| Component | Version |
|---|---|
| **OS** | Ubuntu (Noble/24.04) |
| **Ruby** | 3.4.4 |
| **Rails** | 7.1.5.2 |
| **ActionCable** | 7.1.5.2 |
| **redis** (gem) | 5.0.6 |
| **redis-client** (gem) | 0.22.2 |
| **Redis Server** | 8.0.5 |

---------

Co-authored-by: tejush <mac@MacBook-Air.local>
…a_id (chatwoot#15702)

WhatsApp Cloud attachments are now uploaded to Meta's media endpoint and
sent by `media_id` instead of a public `link`. Meta no longer downloads
the file from the Chatwoot server, so outbound media stops failing
intermittently with `131053: Media upload error` on hosts that share an
ASN with other Chatwoot/Evolution API instances — Meta's `fwdproxy` rate
limits per destination ASN, not per WABA account. It also removes the
secondary failure where `fwdproxy` does not follow ActiveStorage's `302`
redirect and reports `Unsupported mime type text/html`.

Operators who need the previous behaviour can set
`WHATSAPP_MEDIA_UPLOAD_STRATEGY=link`. No migrations, no config required
on upgrade.

Supersedes chatwoot#15069 by @AdarshJ173, which established this approach and
the `media_id` direction. The review points raised by @nestordavalos on
that PR are addressed here.

## Closes

- Closes chatwoot#13540
- Supersedes chatwoot#15069

## How to reproduce

On a WhatsApp Cloud inbox, send an image, document or voice note from an
instance whose hosting provider shares an ASN with other Chatwoot
deployments (e.g. Hostinger, ASN 47583). Meta's webhook returns:

```
Downloading media from weblink failed with http code 429
ratelimit reason: Request ratelimit by fwdproxy
[GlobalCountingRatelimiter] Request hit ratelimit policy — destination matcher asn_list: 47583
```

After this change the file is uploaded first and the message references
the returned `media_id`, so Meta never fetches it from us.

## What changed

- **New `Whatsapp::MediaUploadService`** — streams the blob to `POST
/{version}/{phone_number_id}/media` as multipart form data and returns
`{ 'id' => media_id }`, or `nil` when the upload is disabled or fails.
- **`WhatsappCloudService#build_attachment_content`** sends the returned
media object, falling back to `{ 'link' => download_url }` so a
media-endpoint failure never blocks a message.
- Uses `Faraday::Multipart::FilePart`, matching
`Telegram::SendAttachmentsService`, the codebase's existing multipart
upload. The file part carries an explicit filename and content type.
- Reads the blob through `blob.open` and streams it via Faraday's
`CompositeReadIO`, so large attachments are never buffered in memory and
any ActiveStorage backend works.
- Follows `WHATSAPP_API_VERSION` via `GlobalConfigService`, matching
`calls_phone_id_path` in the enterprise provider, rather than pinning a
version in source.
- Falls back to the link on `Faraday::Error` or a non-success response
only; other errors surface instead of silently degrading.
- Logs one warning naming the fallback and Meta's own error, including
for non-JSON error pages: `[WHATSAPP] Media upload failed, falling back
to link for account … attachment …: HTTP 429 Request ratelimit by
fwdproxy`.

The `voice`/`caption`/`filename` fields and the existing `audio/opus` →
`audio/ogg` normalisation are unchanged; the normalisation now also
governs the uploaded part's content type.

---------

Co-authored-by: A.Adarsh Jagannath <adarshjagannath777@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
## Problem
Widget file uploads via ActiveStorage DirectUpload fail on production
with:

```text
ActionController::InvalidAuthenticityToken (Can't verify CSRF token authenticity.)
```

This occurs because `Api::V1::Widget::DirectUploadsController` inherits
from `ActiveStorage::DirectUploadsController` →
`ActiveStorage::BaseController`, which has protect_from_forgery with:
:exception enabled.

While CSRF works on same-origin, it fails in production because the
widget is embedded in an iframe on third-party customer sites
(cross-origin)


## Solution
Skip CSRF verification for the widget direct uploads endpoint - this is
safe because:

- Widget endpoints authenticate via website_token + X-Auth-Token
(token-based auth)
- CSRF protection is designed for session-based auth, not token-based
auth

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
…hatwoot#14050)

Summary
This PR fixes XSS vulnerabilities by sanitizing unsafe HTML rendering
paths:

Replaced unsafe
[v-html](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)/[innerHTML](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)
rendering with
[v-dompurify-html](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)
in captain assistant UI components
Sanitized signup terms HTML before rendering in the signup form
Added server-side markdown sanitization in
[chatwoot_markdown_renderer.rb](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)
before marking HTML safe
Added regression coverage for markdown XSS sanitization.
Changed files

[MessageList.vue](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[ScenariosCard.vue](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[Index.vue](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[Form.vue](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[chatwoot_markdown_renderer.rb](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[chatwoot_markdown_renderer_spec.rb](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)
Testing
bundle exec rspec spec/lib/chatwoot_markdown_renderer_spec.rb
Optional frontend validation by building the dashboard and confirming
the updated components render safely without unsafe HTML injection
paths.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
…15062)

Ruby 3.4 with YJIT enabled inlines constant values at compile time, so
`stub_const` in tests can't replace a class-level constant that YJIT has
already cached — the spec calls still see the original value and the
session-limit tests return 200 instead of 409.

## What changed

- Removed the `MAX_SESSIONS` class constant from
`DeviseOverrides::SessionsController`
- `sessions_limit_reached?` now reads `ENV.fetch('MAX_USER_SESSIONS',
25).to_i` directly on each call (with a guard for `<= 0`)
- Updated the spec to use `with_modified_env` instead of `stub_const` so
the limit is configurable at test time without hitting the YJIT cache

## How to test

Run the session limit enforcement specs:

```
bundle exec rspec spec/controllers/devise_overrides/sessions_controller_spec.rb
```

All tests should pass, including on Ruby 3.4 with YJIT enabled.

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Fixes chatwoot#15797 - credit to @Uankur for the precise report and diagnosis
(exact citations, repro curls, and the proposed direction); this PR
implements the fix they sketched, verified independently against current
`develop`. @Uankur - would love your eyes on this.

## Problem

`GET /api/v1/accounts/:account_id/portals/:slug` returns HTTP 500 when
the slug does not exist in the account. `fetch_portal` uses
`find_by(slug:)`, which returns nil instead of raising, and `show` then
calls `@portal.articles` on nil. The blast radius is wider than `show`:
`fetch_portal` runs as a `before_action` for every portal action except
`index`/`create`, so `update`, `destroy`, `archive`, `logo` and
`send_instructions` hit the same nil crash (EE `ssl_status` too).

## Fix

One-token change: `find_by` -> `find_by!`.
`ActiveRecord::RecordNotFound` then flows through the existing
`handle_with_exception` rescue, which renders the standard 404
`"Resource could not be found"` - the same pattern sibling controllers
already use (`labels_controller` uses `find`, and the public portals
controllers use `find_by!(slug:)`). The lookup stays scoped to
`Current.account.portals`, so a slug belonging to another account also
returns 404, matching the reporter's expectation. Pundit authorization
is class-level here and unchanged.

## Tests

Two request specs added to `portals_controller_spec.rb`: unknown slug ->
404 with the standard body, and cross-account slug -> 404.

**Test status (honest):** the specs are written but NOT executed - no
Ruby toolchain was available in our environment. Verification was
static: the nil path, the `handle_with_exception` RecordNotFound -> 404
rescue, and the sibling/public-controller `find_by!` precedent were all
confirmed against current `develop` (`2f1ed80f89`). The one-token change
mirrors the repo's own usage elsewhere.

> Built by breken, your AI support engineer - breken.ai - this one's on
us.
## Description

This fixes Facebook Messenger postback events not being received by
Chatwoot inboxes.

Previously, Chatwoot subscribed Facebook pages without the
`messaging_postbacks` webhook field, so actions like `Get Started` and
button postbacks were not delivered. This change adds the missing
subscription field, registers a Messenger `:postback` handler, and maps
postback payloads into the existing Facebook inbound message flow.

Fixes chatwoot#8821

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Manual reproduction steps:
1. Create or connect a Facebook Page inbox in Chatwoot.
2. Configure a Messenger `Get Started` button or a button that sends a
postback.
3. Trigger the postback from Messenger.
4. Confirm the event is received and created through the existing
Facebook inbound message pipeline.

Automated coverage added:
- Added a model spec to verify `messaging_postbacks` is included in
Facebook page webhook subscriptions.
- Added a builder spec to verify a Messenger postback creates an
incoming message using the postback title.

Note:
- I could not run the full local Ruby test suite in the current
environment because Ruby/Bundler was not available in the active shell.

## 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Include ET translation files

Co-authored-by: Sojan Jose <sojan@pepalo.com>
* fix: authorize conversations before macro execution

* fix: log skipped macro executions and cover authorization
* fix: run pre-authentication checks on header-supplied sign-in credentials

* fix: normalize email in SAML password-auth guard
## Description

Prevent non-Cloud login and application pages, Super Admin, and
installation setup pages from being indexed. Cloud application pages
remain eligible for indexing. Widget pages always include `noindex`, and
their existing crawl exclusion is preserved to avoid creating contacts
from crawler requests. Public Help Center homepages and articles retain
their existing indexing behavior, including when a widget is embedded.

A public Rails route replaces the static robots.txt file and retains
`Disallow: /widget`. The application layout uses the existing Cloud
detection helper to apply `noindex` conditionally; widget, Super Admin,
and setup pages include it on every installation. Public Help Center
layouts remain unchanged.

Because widget crawling remains blocked, crawlers cannot reliably read
its `noindex` tag to remove already-indexed widget URLs. Allowing widget
crawling safely requires a separate change to make widget GET requests
create no records.

## Closes

Closes
https://linear.app/chatwoot/issue/CW-8224/prevent-login-page-indexing-on-non-cloud-installations

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

1. On a self-hosted installation, open the page source for `/app/login`,
`/app/login/sso`, `/app`, `/`, and
`/widget?website_token=<valid-token>`. The HTML head includes `<meta
name="robots" content="noindex">`, including when the manifest is
disabled.
2. Set the installation's `DEPLOYMENT_ENV` to `cloud` with Enterprise
enabled and reload. The application pages no longer include the
directive, while the widget still includes it. Community installations
retain the directive on application and widget pages.
3. Open `/super_admin/sign_in`, sign in, and inspect `/super_admin` and
`/super_admin/accounts`. All include `noindex` on Cloud, self-hosted
Enterprise, and Community installations.
4. On a fresh installation, open `/` and follow the redirect to
`/installation/onboarding`. The setup page includes `noindex`.
5. Open a public Help Center homepage, article, and custom-domain
homepage using either portal layout, including with a widget attached.
None receives the directive. Plain article previews remain unaffected.
6. Open `/robots.txt` without signing in. It returns plain text with
`User-agent: *` and `Disallow: /widget`, including in API-only
deployments. Other paths remain crawlable.

## 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
@sentry

sentry Bot commented Sep 24, 2026

Copy link
Copy Markdown

🚧 Skipped: PR exceeds review size limit.

Please split into smaller PRs and re-run.
Reference ID: 17021618

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@neilLasrado
neilLasrado merged commit 0358609 into master Sep 24, 2026
46 of 50 checks passed
@sentry

sentry Bot commented Sep 24, 2026

Copy link
Copy Markdown

🚧 Skipped: PR exceeds review size limit.

Please split into smaller PRs and re-run.
Reference ID: 17021752

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.