## 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>
# Pull Request Template
## Description
This PR fixes the mention picker staying open when switching between
Reply and Private Note, or when switching conversations while a picker
is open. The picker could keep a stale range from the previous editor
state, and selecting an agent from it would crash the editor with
`RangeError: Position N out of range`.
The pickers now close whenever the editor content is rebuilt, and the
stored suggestion range is cleared before creating the new editor state.
This is a pre-existing issue and not a regression from the picker PR.
For the mention picker to open, `@` needs to be at the start of a line
or preceded by whitespace, so the repro uses `hello @` instead of
`abc@`.
#### What changed
Rebuilding the editor state creates new plugin instances, so the old
suggestion plugin is removed without calling `onExit`. This leaves the
picker's stored range pointing to a document that no longer exists.
`reloadState` now clears the stored suggestion range and closes all open
pickers before rebuilding the editor state.
This is a pre-existing issue and not a regression from the picker PR.
Fixes
https://linear.app/chatwoot/issue/CW-7919/mention-picker-opens-on-an-empty-private-note-and-crashes-when-an
## Type of change
- [x] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
## How Has This Been Tested?
### Steps to reproduce
1. Open a conversation and stay in Reply mode.
2. Type `hello @` to open the mention picker.
3. Switch to Private Note.
4. Click any agent from the still-open picker.
5. The editor crashes with `RangeError: Position N out of range`.
6. The same issue can be reproduced by switching to another conversation
while a picker is open.
## 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
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>
Contact avatars could be skipped when an avatar job was enqueued while
the contact record was still inside an open database transaction. A fast
worker could pick up the job before the record became visible and
discard it after deserialization failed.
This change makes avatar URL jobs wait for the surrounding transaction
to commit before they enter the queue. Avatar jobs scheduled outside a
transaction continue to enqueue immediately, and the existing download,
validation, and rate-limiting behavior remains unchanged.
Fixes
https://linear.app/chatwoot/issue/CW-7917/facebook-contact-avatars-never-sync-avatarfromurljob-is-enqueued
### How to reproduce
1. Receive a Facebook message from a sender who does not yet exist as a
contact.
2. Let the message flow create the contact and schedule its avatar job
within the same transaction.
3. Observe that a worker can attempt to deserialize the contact before
the transaction commits, causing the avatar job to be discarded.
### How to test
1. Receive a Facebook message from a new sender with a profile picture.
2. Confirm the contact and conversation are created successfully.
3. Confirm the avatar job is enqueued after the transaction commits and
the profile picture is attached.
4. Create a contact with an avatar through the Contacts API and confirm
the same behavior.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
# Pull Request Template
## Description
This PR adds search and preview support to the mention, variable, and
emoji pickers, bringing them in line with the canned response picker.
All editor pickers now use the same component with their own search
field, keyboard navigation, caret-anchored placement, and a preview
pane.
Variables show their description and the value resolved for the current
conversation, emojis show the glyph, name, shortcode, and category, and
mentions show relevant agent or team details such as availability, role,
auto-assign, and membership.
This also fixes company custom attributes incorrectly appearing as
contact custom attributes in the variable picker. They could create
duplicate entries and insert variables that always resolved to an empty
value, so they are now excluded.
The old `MentionBox` component has also been removed since the variable
and emoji pickers were its only remaining users.
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
**Emoji Picker**
<img width="1302" height="527" alt="image"
src="https://github.com/user-attachments/assets/a491b149-d547-4ab4-b990-a054faf38ac1"
/>
**Variables Picker**
<img width="1305" height="487" alt="image"
src="https://github.com/user-attachments/assets/42a6d334-4f70-47e2-ada4-531cf8619454"
/>
<img width="444" height="556" alt="image"
src="https://github.com/user-attachments/assets/bccd15e1-e5af-4362-b1b5-27877910670c"
/>
**Tag agents/teams Picker**
<img width="1308" height="502" alt="image"
src="https://github.com/user-attachments/assets/547d9142-a6e5-4606-a9b7-01e824868b29"
/>
<img width="1308" height="502" alt="image"
src="https://github.com/user-attachments/assets/28cd4919-5875-462a-adc8-90c0fd8e8a3f"
/>
<img width="444" height="556" alt="image"
src="https://github.com/user-attachments/assets/33eaa131-c033-49e4-aa93-343c2a976d5b"
/>
### Steps
1. Type `@` in a private note. Verify agents and teams appear under
separate headings, and the preview shows availability and role for an
agent, and auto-assign and membership status for a team.
2. Type `{{` in a reply. Verify the preview shows the variable
description and the value resolved for the current conversation. Also
verify that a variable with no value clearly indicates that no value is
available.
3. Verify company custom attributes no longer appear in the variable
picker.
4. Type `:` followed by at least two characters. Verify emojis can be
searched by name and shortcode, and the preview shows the glyph, name,
and category.
5. For each picker, navigate with the arrow keys or Tab, insert with
Enter or a click, and close with Escape.
6. Reopen a conversation with a draft containing `@name`, `/shortcode`,
or `{{contact`. Verify the corresponding picker opens with the existing
text populated in its search field.
## 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
- [x] I have made corresponding changes to the documentation
- [ ] 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
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](a4eae9710a/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](a4eae9710a/app/javascript/dashboard/store/utils/api.js (L28-L36))
- [Configured two-month token
lifetime](a4eae9710a/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`.
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.
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>
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>
# 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
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>
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: https://github.com/chatwoot/chatwoot/pull/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>
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.
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: https://github.com/chatwoot/chatwoot-mobile-app/pull/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>
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.
## 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](https://github.com/chatwoot/chatwoot/pull/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>
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>
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
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>
# Pull Request Template
## Description
The unsaved changes panel in the Help Center article editor now uses the
shared `SidePanel` component instead of its own custom drawer. It now
matches the rest of the dashboard, with the same slide-in animation,
backdrop, and close button for a consistent experience.
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
### Screenshots
**Before**
<img width="1530" height="879" alt="image"
src="https://github.com/user-attachments/assets/4ef7eafb-0cd6-455f-a970-e1411a24ef25"
/>
**After**
<img width="1530" height="879" alt="image"
src="https://github.com/user-attachments/assets/be99ee0d-1906-4a49-967b-3ba1f4fa40b6"
/>
## 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
# Pull Request Template
## Description
This PR fixed the conversation list was incorrectly rendering the avatar
upload overlay on every conversation card, even though uploads aren't
supported there. Clicking it could throw a `TypeError: Cannot read
properties of null (reading 'click')`. Conversation cards now only show
the selection checkbox, while avatar uploads continue to work everywhere
they're supported.
### Cause
`ConversationCard` always passed the `#overlay` slot, but the checkbox
inside it was wrapped in `v-if`. When the checkbox wasn't rendered, Vue
treated the slot as empty and fell back to the default upload overlay
from `Avatar`.
That overlay's click handler expects a file input, but the file input is
only rendered when `allowUpload` is enabled. Since conversation cards
never enable uploads, clicking the overlay could dereference a null file
input and throw.
### How to reproduce
This isn't reliably reproducible manually. It only happens when the
upload overlay becomes visible while the card's internal hover state is
out of sync with the browser's CSS `:hover` state. In normal
interaction, entering the card immediately updates the hover state and
shows the checkbox instead, so the issue effectively self-recovers.
The new test reproduces this state directly and verifies the fix.
## What changed
* Moved `v-if="allowUpload"` from the hidden file input to the upload
overlay itself, so the overlay and file input are always mounted
together.
* Added `Avatar.spec.js` coverage for the overlay slot, including the
empty-slot case that triggered this bug, along with the existing upload,
delete, badge, sizing, initials, and image fallback behavior.
Fixes
https://linear.app/chatwoot/issue/CW-7726/typeerror-cannot-read-properties-of-null-reading-clickhttps://chatwoot-p3.sentry.io/issues/7291677410/?project=4507182691975168&referrer=Linear
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Screenshots
**Before**
<img width="1683" height="847" alt="image"
src="https://github.com/user-attachments/assets/1b746be4-0425-4a68-953b-193e70411032"
/>
**After**
<img width="1683" height="847" alt="image"
src="https://github.com/user-attachments/assets/e0c78aba-6053-442c-905a-373c8d2be123"
/>
## 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
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>
Cloud accounts without access to WhatsApp Quick setup currently fall
directly into manual configuration, with no explanation of how to
request the easier Meta flow.
This adds a compact access-request card above manual setup. It explains
that Quick setup with Meta can connect either a new number or an
eligible existing number from the WhatsApp Business app, then opens the
existing support widget so the account can be reviewed. The enabled
state now uses the same customer-facing language.
Related: https://github.com/chatwoot/chatwoot/pull/15318
<img width="1428" height="1110" alt="CleanShot 2026-08-05 at 14 12
31@2x"
src="https://github.com/user-attachments/assets/914e5fd7-eb5a-438c-9706-0ecd8e004fbf"
/>
### Things to know
- The access card is Cloud-only and appears when the account-level
WhatsApp Quick setup feature is disabled.
- The global Meta incident restriction still takes precedence and
continues to show the existing incident warning.
- Manual setup remains available, and self-hosted behavior is unchanged.
- Requesting access opens Chatwoot support; it does not automatically
enable the account feature.
- Meta documents the existing WhatsApp Business app number path as
coexistence onboarding:
https://developers.facebook.com/documentation/business-messaging/whatsapp/embedded-signup/onboarding-business-app-users
### How to test
1. On Chatwoot Cloud, set `DISABLE_META_INBOX_CREATION` to `false`.
2. Open WhatsApp inbox creation for an account without
`whatsapp_embedded_signup_inbox_creation`.
3. Confirm the Quick setup with Meta request card appears above the
unchanged manual configuration form.
4. Select **Request access** and confirm the support widget opens.
5. Enable `whatsapp_embedded_signup_inbox_creation` for the account and
reload.
6. Confirm the enabled Quick setup with Meta screen appears and
describes both new numbers and eligible existing WhatsApp Business app
numbers.
7. Set `DISABLE_META_INBOX_CREATION` to `true` and confirm the incident
warning appears instead of the access-request card.
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
WhatsApp Business app Coexistence inboxes currently see the manual
migration prompt even though they cannot move to the generic manual
setup flow without losing the supported Coexistence path.
This change waits for WhatsApp health data and shows manual transfer
only when Meta confirms the number is not connected to the WhatsApp
Business app. Other eligible Embedded Signup inboxes continue to see the
migration prompt.
### Things to know
- This is frontend-only gating; the migration API behavior is unchanged.
- The prompt remains hidden while the WhatsApp Business app state is
unknown or unavailable.
### How to test
1. Enable `whatsapp_manual_transfer` for an account with an Embedded
Signup WhatsApp inbox.
2. Open an inbox whose health response reports `is_on_biz_app: true`;
confirm the migration banner and dialog are unavailable.
3. Open an inbox whose health response reports `is_on_biz_app: false`;
confirm the migration banner appears and opens the existing manual
migration dialog.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@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
When `POST /api/v1/accounts/:id/conversations/filter` fails (for example
a 500 or a server-side timeout), the conversation list spinner never
clears because the Vuex action's catch block was empty. The user is
stuck on an infinite loader with no way to retry.
This change:
- Clears the list loading state (`CLEAR_LIST_LOADING_STATUS`) and
rethrows in `fetchFilteredConversations`, matching how sibling actions
in the module handle errors. Both applied filters and saved filters
(custom views) go through this action.
- Catches the rejection in `ChatList.vue` and shows an alert ("Couldn't
load conversations. Please try again.") so the user can retry.
- Guards the reconnect flow in `ReconnectService` so a failed filtered
fetch on websocket reconnect does not abort cache revalidation.
- Fixes the existing spec for the success path, which passed no
`dispatch` and was silently exercising the error path.
No client-side request timeout was added since the dashboard API layer
has no per-request timeout convention; the fix is scoped to error
handling.
Fixes https://linear.app/chatwoot/issue/CW-7831
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Added a spec asserting the loading state clears and the error is
rethrown when the filter request rejects: `pnpm test
app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js`
(49 passed).
- `pnpm test
app/javascript/dashboard/helper/specs/ReconnectService.spec.js` (24
passed).
## 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
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
## 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.
SLA is a premium feature, but several SLA surfaces never checked the
account's `sla` flag. An account without SLA — or one whose plan was
downgraded and had the flag revoked — could still read SLA breach
reporting, attach an SLA policy to a conversation through the
conversation update endpoint, and keep applying SLA policies from
automation rules. All three paths now require the feature, matching the
SLA policies API which already checked it.
## How to reproduce
1. Disable the `sla` feature for an account that has SLA policies and
applied SLAs.
2. `GET /api/v1/accounts/{id}/applied_slas/metrics` (also `index` and
`download`) — returns the account's SLA breach data instead of denying.
3. `PATCH /api/v1/accounts/{id}/conversations/{display_id}` with
`sla_policy_id` — the policy is attached.
4. Trigger an automation rule with an "Add SLA" action — the SLA is
applied and starts tracking.
## What changed
- `Api::V1::Accounts::AppliedSlasController` gains the same
`ensure_sla_feature_enabled` guard the SLA policies controller uses,
covering `index`, `metrics` and `download`.
-
`Enterprise::Api::V1::Accounts::ConversationsController#permitted_update_params`
only permits `sla_policy_id` when the feature is enabled. When it is off
the parameter is dropped, so an existing SLA association is preserved
rather than cleared.
- `Enterprise::ActionService#add_sla` returns early when the feature is
off, so automation rules stop applying SLAs on revoked accounts.
Conversations could get reassigned repeatedly in quick succession —
bouncing between agents with several "Assigned to X" activity messages
in a row — whenever an assignment write raced against another assignment
write on the same conversation (an automation rule vs. Assignment V2's
auto-assign, two manual assignment clicks, two concurrent status
transitions triggering legacy round-robin, etc). This was reproducible
with or without Assignment V2 enabled; it wasn't a V2-specific issue,
just more visible under message bursts. Assignment V2's own job-vs-job
race was already fixed separately (#14495) — this PR covers the other
writers that don't check the conversation's current state before
overwriting it.
## How to reproduce
Configure an automation rule that assigns an agent/team on
`message_created`, then send a burst of messages on one conversation
from a channel/inbox with concurrent message delivery (or race it
against another assignment source — a manual assignment click, or a
status change that triggers legacy auto-assignment). The conversation's
assignee flips between agents multiple times, each producing its own
activity message, even though later writes were often redundant (setting
the assignee to a value it already had, from the writer's stale point of
view).
## What changed
Three assignment write paths now take a row lock (`with_lock`) before
writing, so a concurrent writer re-reads the conversation's true current
state before deciding whether a write is actually needed:
- `ActionService#assign_agent`/`#assign_team` (and the corresponding
unassign methods) — used by automation rules, macros, and delayed
automations.
- `Conversations::AssignmentService#assign_agent`/`#assign_agent_bot` —
the dashboard's assignee dropdown (human agent or AgentBot).
- `AutoAssignment::AgentAssignmentService#perform` — the legacy (non-V2)
round-robin auto-assignment path.
A write that would just reapply an already-current value becomes a
genuine no-op instead of producing a redundant `UPDATE` and duplicate
activity message. Legitimate reassignment (a real change in target, or
reassignment away from an agent who lost inbox access) is unaffected.
This restores the temporary Meta incident safeguards with two
independent runtime controls for Chatwoot Cloud. Super admins can now
re-enable Instagram messaging first and keep new Meta inbox creation
disabled until onboarding is stable.
When messaging is disabled, Instagram conversations show the incident
notice and remain in private-note mode. The separate inbox-creation
control disables Facebook, Instagram, and WhatsApp Embedded Signup entry
points. Self-hosted installations remain unchanged.
Related: https://github.com/chatwoot/chatwoot/pull/15210
Related: https://status.chatwoot.com/incident/976975
### Things to know
- `DISABLE_META_MESSAGE_SENDING` affects Instagram messaging only.
- `DISABLE_META_INBOX_CREATION` affects Facebook, Instagram, and
WhatsApp Embedded Signup inbox creation.
- Both controls default to `true` for the active incident and are
editable from Super Admin installation configs.
- The Cloud-only boundary is enforced by the dashboard configuration
getters.
### How to test
1. Open Super Admin → Installation Configs and set
`DISABLE_META_MESSAGE_SENDING` to `false`.
2. Reload an Instagram conversation and confirm the incident banner
disappears and the runtime restriction no longer forces private-note
mode.
3. Set the flag back to `true`, reload, and confirm the banner and
restriction return.
4. Set `DISABLE_META_INBOX_CREATION` to `false`, reload the Facebook,
Instagram, or WhatsApp Embedded Signup setup flow, and confirm
connection is enabled.
5. Set the flag back to `true`, reload, and confirm the incident notice
appears and connection is disabled.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>