Commit Graph

6585 Commits

Author SHA1 Message Date
Aakash Bakhle
bdbbaa38de feat(captain): add inactivity timer backend (2/5) (#15303)
Captain V2 assistants can now persist a configurable inactivity timer
and choose whether inactivity resolution sends the saved closing message
or resolves silently. This PR contains only the API, persistence,
runtime behavior, and backend specs.

## Closes

[AI-163](https://linear.app/chatwoot/issue/AI-163)

## Depends on

Stack 2 of 5. Based on the assistant-policy foundation in #15299. The
frontend follows in #15308.

## What changed

- Added per-assistant inactivity duration and resolution-message
settings with safe defaults.
- Restricted the Part 2 settings API to Captain V2 while keeping the
Part 1 policy mode available without V2.
- Updated inactivity handling to use the assistant timer and skip the
public resolution message when disabled.
- Serialized the effective timer and message settings for the frontend.
- Added model, request, and job coverage, including the explicit Captain
V2 boundary.

## How to test

1. Enable Captain V2 and update `auto_resolve_after` and
`send_inactivity_resolution_message` through the assistant API.
2. Run the inactivity job and confirm it uses the assistant timer.
3. Disable the resolution message and confirm the conversation resolves
silently.
4. Disable Captain V2 and confirm timer/message updates are ignored
while `auto_resolve_mode` remains updateable.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-10 17:33:04 +05:30
Muhsin Keloth
9a9c88494e feat(whatsapp): improve template listing controls (#15377)
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>
2026-08-10 16:14:40 +05:30
Sojan Jose
81fc35e9e6 chore: upgrade Rails to 7.2.3.1 (#13437)
This upgrades Chatwoot to Rails 7.2.3.1 while retaining the current
Rails 7.0 framework defaults, so the runtime upgrade can be deployed and
observed independently from default-behavior changes.

## What changed

- Upgrade Rails and the compatible dependency set to Rails 7.2.3.1.
- Keep `config.load_defaults 7.0` for a staged, lower-risk rollout.
- Replace the unmaintained Azure Active Storage fork with the maintained
`azure-blob` adapter while preserving the `microsoft` service name.
- Pin Sidekiq 7.3.10 with `connection_pool` 2.x after validating
scheduled-job execution against Redis.
- Update Rails 7.2 compatibility surfaces in Active Record, strong
parameters, migrations, storage, and tests.
- Add read-only production preflight checks, an opt-in Active Storage
smoke script, a deployment runbook, and the full Rails 7.2/8.0/8.1
assessment.

## How to test

1. Sign in and verify the dashboard and conversation UI load normally.
2. Open the agent-management modal and confirm agent data is rendered.
3. Create an API inbox and wait for the asynchronous deletion flow to
complete.
4. Open Super Admin pages, including instance status and account-user
management.
5. Upload and download an attachment using the configured Active Storage
service.
6. Confirm recurring Sidekiq Cron jobs register and execute after
startup.

## Rollout

Follow `docs/rails_upgrades/7_2.md` for pre-deploy checks, deployment
order, smoke tests, monitoring, and rollback. Run `bundle exec rails
runner script/rails_upgrade/preflight.rb` against a
production-equivalent environment before rollout.

## Tracking

- [CW-5863 — Upgrade Rails to
8+](https://linear.app/chatwoot/issue/CW-5863/upgrade-rails-to-8)
- [Rails 7.2 to 8.1 upgrade and production rollout
plan](https://linear.app/chatwoot/document/chatwoot-rails-72-to-81-upgrade-and-production-rollout-plan-44e9f4964cb2)

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-08-10 15:49:38 +05:30
Aakash Bakhle
a4eae9710a test: Add focused Captain response lifecycle logs (#15364)
## 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.
2026-08-09 09:01:03 +05:30
Sojan Jose
f12529105b fix: align AgentBot ownership with conversation counts (#15343)
AgentBot-owned conversations are now treated as assigned across
conversation lists, counts, pagination, permissions, unread membership,
human auto-assignment, and advanced assignee filters.

## Closes

-
https://linear.app/chatwoot/issue/CW-7689/align-agent-bot-ownership-with-unassigned-counts-and-pagination

## Follow-ups

-
https://linear.app/chatwoot/issue/CW-7870/refresh-agentbot-ownership-state-when-deleting-an-agent-bot
tracks ownership refresh during AgentBot deletion.
-
https://linear.app/chatwoot/issue/CW-7899/refresh-saved-filter-totals-after-live-conversation-ownership-changes
tracks the existing saved-filter header count refresh gap.

## Why

The backend treated every conversation without a human assignee as
unassigned, even when an AgentBot owned it. The frontend already hid
AgentBot-owned conversations from the Unassigned list, so counts,
pagination, filters, unread membership, direct-access permissions, and
auto-assignment could disagree with the visible queue.

## What changed

- Treat conversations with either a human assignee or AgentBot owner as
assigned.
- Keep conversation counts, pagination, unread memberships, advanced
filters, and automation assignee conditions aligned with the shared
ownership semantics.
- Keep human assignee equality and not-equality filters human-only, even
when a human and AgentBot have the same numeric ID.
- Exclude AgentBot-owned conversations from both legacy and V2 human
auto-assignment, and from unassigned-only Enterprise access.
- Preserve AgentBot ownership when Twilio or WhatsApp call flows reuse
or accept an assigned conversation.
- Emit ownership-change updates when only the AgentBot owner changes, so
connected clients refresh queue state.

## Validation

- AgentBot assignment changed ownership to the bot, moved the
conversation to pending, and removed it from the open queue.
- Assignee "is present" returned human- and AgentBot-owned
conversations; "is not present" returned only genuinely unassigned
conversations.
- Automation assignee presence conditions treated AgentBot ownership as
present and did not execute the absent-owner path.
- Live human-assignee equality and not-equality filters excluded
AgentBot-owned conversations, including numeric ID collisions.
- A 32-conversation pending queue loaded across pagination with matching
totals and no missing or duplicate rows.
- AgentBot ownership changes and human takeover updated filtered rows
immediately without a reload.
- Human takeover opened the conversation and restored the public reply
composer; subsequent unassignment kept the conversation open.
- An unassigned-only custom-role agent saw only genuinely unassigned
conversations and could not see AgentBot-owned conversations.
- AgentBot-owned conversations showed the handled-by-bot banner, Take
over action, and disabled public reply composer.
- New conversations in the connected inbox were assigned to the AgentBot
and excluded from human auto-assignment.
- Opening an AgentBot-owned conversation did not let the legacy
assignment callback or its locked recheck replace the bot.
- Moving an AgentBot-owned conversation to an auto-assigning team
preserved the bot and did not create a second human owner.
- Twilio conference pickup, Twilio outbound reuse, WhatsApp outbound
reuse, and inbound WhatsApp acceptance preserved existing AgentBot
owners.
- Focused ownership, filters, pagination, permissions, unread-count,
auto-assignment, frontend, and lint checks passed locally.
- GitHub Actions, Docker builds, CircleCI, security checks, and the
final Codex review are green on the final head.
2026-08-07 14:17:34 -07:00
Sojan Jose
35a5f3390c feat: update status on agent bot assignment (#14870)
Assigning a conversation to an Agent Bot now moves it to pending.
Assigning a bot-owned pending conversation to a human opens it again,
while other assignment changes preserve the existing status. This makes
existing Agent Bot ownership behave like an AI handoff without depending
on the assignment dropdown UI work.

Closes:
https://linear.app/chatwoot/issue/CW-7448/apply-agent-bot-assignment-behavior

## Why
Agent Bot ownership should remove conversations from the main open queue
while the bot is handling them. Explicit human takeover should bring a
bot-owned pending conversation back to the open queue and clear the bot
owner.

## What changed
- Agent Bot assignment clears the human assignee and marks the
conversation pending.
- Human assignment clears the Agent Bot owner and opens the conversation
only when it was bot-owned and pending.
- Ordinary human assignment, non-pending bot takeover, and unassignment
preserve the existing conversation status.
- Manual human takeover uses the existing assignment and status events.
Bot-initiated handoffs continue to use the existing bot-handoff event
path.

## Validation
- Assign an open conversation to an Agent Bot through the assignment API
and verify it becomes pending.
- Assign that bot-owned pending conversation to a human and verify it
becomes open.
- Verify ordinary human assignment, non-pending bot takeover, and
unassignment do not force a status change.
2026-08-07 12:08:58 -07:00
Sivin Varghese
f770c585bd fix: prevent vertical tab labels from overlapping (#15363) 2026-08-07 22:18:46 +05:30
Muhsin Keloth
c8e95efb4c fix(whatsapp): return empty template arrays for malformed inbox data (#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: 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>
2026-08-07 15:23:19 +05:30
Pranav
0f3bb640f5 feat(captain): Add audience and schedule controls for assistants (#14902)
Captain assistants now support **audience** and **schedule** controls,
so you can decide *who* an assistant replies to and *when* it's on duty.
By default nothing changes, an assistant still responds to every
conversation in its connected inboxes but you can now narrow that down.

- **Audience**: build a condition tree (contact attributes, conversation
attributes, and custom attributes) with and/or groups, mirroring the
contact-segment filter semantics. Only conversations whose contact
matches the audience get a Captain reply.
- **Schedule**: choose when Captain replies — *Anytime*, *During
business hours*, or *Outside business hours* (based on each inbox's
configured working hours; inboxes without business hours are always
covered).

When an assistant opts out of a conversation (contact outside the
audience, or off-schedule), the conversation is routed to the human
queue instead of being parked pending on a silent bot — both on initial
creation and on reopen.

Fixes
https://linear.app/chatwoot/issue/CW-7414/audience-and-availability-controls

|Audience|Availability|
|--|--|
| <img width="1132" height="627" alt="Screenshot 2026-06-30 at 5 52
09 PM"
src="https://github.com/user-attachments/assets/866910e0-e1d7-4248-8630-d91afc758688"
/> | <img width="1131" height="539" alt="Screenshot 2026-06-30 at 5 52
13 PM"
src="https://github.com/user-attachments/assets/aad0d6f7-ceb7-4546-a049-095c5b46b483"
/> |

## How to test

1. Open **Captain → Assistants → (an assistant) → Settings**.
2. Under **Audience**, add a condition or condition group (e.g. `Contact
language equal_to en`) and save. Start a conversation from a contact
that does *not* match — Captain should stay silent and the conversation
should land in the human (open) queue instead of pending.
3. With a matching contact, Captain should respond as before.
4. Under **Schedule**, pick **During business hours** (or **Outside
business hours**) on an inbox that has working hours configured, and
confirm Captain only engages within/outside that window. An
empty/`Anytime` schedule always responds.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-07 13:25:48 +05:30
Muhsin Keloth
834e9a4044 fix(whatsapp): correctly sync Twilio template approval status (#15353) 2026-08-07 06:58:53 +04:00
Tanmay Deep Sharma
0df9508893 fix(security): keep inbox access filtering on the participating scope (#15207)
An agent who is removed from an inbox could still see that inbox's
conversations under the **Participating** filter. Removing an agent from
an inbox does not delete the conversation participant records they
already had, and the participating filter was ignoring inbox access
entirely — so those conversations stayed visible indefinitely. The
filter now respects inbox access like every other conversation filter.

## Linear Ticket
- https://linear.app/chatwoot/issue/CW-6923

## How to reproduce

1. Add an agent to two inboxes, A and B.
2. As that agent, become a participant on a conversation in inbox B
(open it, or get added as a participant).
3. Remove the agent from inbox B in Settings → Inboxes → Collaborators.
4. Log in as the agent and open Conversations → Participating.
5. The inbox B conversation is still listed, and is openable.

## What changed

`ConversationFinder#filter_by_conversation_type` **replaced**
`@conversations` with `current_user.participating_conversations` for the
`participating` type, discarding the inbox/permission-filtered scope
built up by `Conversations::PermissionFilterService` immediately before
it. It now narrows the existing scope by participating ids instead, so
permission filtering survives.
2026-08-06 19:00:30 +05:30
Sony Mathew
e3e35ab7e1 feat: enable data imports for paid plans (#15346)
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
2026-08-06 17:26:12 +05:30
Tanmay Deep Sharma
473ac39489 feat: nudge users with a dashboard banner when backup codes run low (#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](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>
2026-08-06 16:17:58 +05:30
Tanmay Deep Sharma
cefb3fea54 fix(voice): sync inbound WhatsApp call accept state across tabs (#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>
2026-08-06 16:17:30 +05:30
Sivin Varghese
240aca1d7a fix: keep bulk action bar on a single line for longer translations (#15339) 2026-08-06 14:59:52 +05:30
Vishnu Narayanan
17d927554d fix: block the unused Active Storage direct-upload route (#15329)
## Description

**Problem.** The default Active Storage upload route, `POST
/rails/active_storage/direct_uploads`, is mounted automatically by Rails
and requires no authentication. Chatwoot doesn't rely on it, our
dashboard and widget uploads all use scoped, authenticated endpoints, so
the route just sits there letting anyone create blobs anonymously.

**Fix.** Block the built-in route so it returns `403`. Chatwoot's own
upload controllers inherit from the same Rails class but are left
working, an `instance_of?` check makes the block apply only to the bare
route, not to the subclasses that call `super`.

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

## Type of change

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

## How Has This Been Tested?

Added a request spec asserting the bare route returns `403` and creates
no blob. Existing widget and conversation direct-upload specs still
pass, confirming the scoped endpoints are unaffected.

13 examples, 0 failures across the three direct-upload specs; rubocop
clean.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-08-06 14:25:19 +05:30
Sony Mathew
696d2a5d37 fix: add default names to data imports (#15345)
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
2026-08-06 13:20:56 +05:30
Tanmay Deep Sharma
430c5cfef0 fix(whatsapp): refresh inboxes when opening new conversation composer (#15337)
When an agent starts a new conversation, the WhatsApp template picker in
the composer only shows templates that were already loaded into the
frontend store — it doesn't refresh when the composer opens. If a
template sync completed after the store was last populated, the newly
synced template shows up on the inbox's Settings > Templates page (which
always refetches on load) but not in the New Conversation composer,
since that view relied solely on the account-cache-invalidated websocket
event, which doesn't always reach an already-open session in time.

## What changed
- `ComposeConversation.vue` now dispatches a cache-aware `inboxes/get`
refetch every time the composer popover opens, so the WhatsApp template
list is current before an agent picks a template to message a customer.
The refetch checks the account's cache key first and only re-pulls the
full inbox list when it's actually stale, so it stays cheap in the
common case.

## How to reproduce
1. Sync/update WhatsApp templates for an inbox (e.g. via Settings >
Inboxes > [WhatsApp inbox] > Sync Templates).
2. Without reloading the page, open the New Conversation composer for
that inbox and check the WhatsApp template picker — a newly synced
template may be missing until this fix.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-08-06 12:26:12 +05:30
Sivin Varghese
78069b01f6 fix: make the changelog card visible in dark mode (#15340) 2026-08-06 10:32:11 +05:30
Sivin Varghese
ce06121587 fix: prevent command bar crash and hide inaccessible commands (#15322) 2026-08-05 19:32:07 +05:30
Sivin Varghese
f2cf81e7ff chore: use SidePanel component for the article diff panel (#15333)
# 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
2026-08-05 17:10:52 +05:30
Sivin Varghese
f58f08a40d fix: prevent avatar upload overlay from showing in conversation list (#15332)
# 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-click

https://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
2026-08-05 17:07:26 +05:30
Vishnu Narayanan
a72b49a981 fix: validate days_before filter values before date arithmetic (#15331)
## Description

Follow-up to #15319, addressing the non-blocking review notes and the
Ito QA finding there.

The `days_before` filter value went straight through `to_i`, which never
fails: `-1` moves the cutoff into the future and matches every
conversation, and a non-numeric string becomes `0`. The value is now
parsed as a base-10 integer and must fall within the UI-supported
`1..998` range; anything else raises the existing
`CustomExceptions::CustomFilter::InvalidValue`, which the controller
already turns into a client error.

Also corrects an existing weak spec that sent `3` days but computed its
expectation with `2` days, passing only because the seeded data made
both counts equal. It now sends `2` and exercises the exclusive
boundary.

Refs https://linear.app/chatwoot/issue/CW-7832

## Type of change

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

## How Has This Been Tested?

- New specs: invalid values (`-1`, `abc`, `0`, `999`) raise
`InvalidValue`; string values parse as base 10 (`'02'` means 2 days, not
octal).
- `bundle exec rspec spec/services/conversations/filter_service_spec.rb`
(36 examples, 0 failures).

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-05 17:04:57 +05:30
Sojan Jose
3b74f9c359 fix: prevent Captain bot collisions (#15324)
Prevents Captain from being scheduled for replies or
inactive-conversation resolution when an inbox already has an active
AgentBot or Dialogflow integration.

## Closes


[CW-7834](https://linear.app/chatwoot/issue/CW-7834/prevent-captain-from-processing-agentbot-and-dialogflow-conversations)

## Why

Captain and an external inbox bot could both process conversations from
the same inbox.

## What this change does

- Distinguishes external inbox bots from Captain in the existing bot
predicate.
- Skips Captain reply scheduling when an external bot is active.
- Skips Captain inactive-resolution scheduling when an external bot is
active.

## Validation

- Connect an AgentBot to a Captain-enabled inbox and confirm Captain
does not reply or schedule inactive resolution.
- Enable Dialogflow on a Captain-enabled inbox and confirm Captain does
not reply.
- Remove the external bot integration and confirm Captain resumes normal
processing.

---------

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-08-05 16:34:46 +05:30
Muhsin Keloth
343bb15d07 feat(whatsapp): add quick setup access request (#15336)
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>
2026-08-05 15:01:34 +04:00
Muhsin Keloth
0669dc4ea5 fix(whatsapp): hide manual transfer for coexistence inboxes (#15330)
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>
2026-08-05 14:57:29 +04:00
Muhsin Keloth
781943867b fix(whatsapp): preserve and display flow responses (#15279)
WhatsApp Flow submissions currently reach Chatwoot as
`interactive.nfm_reply` messages, but their structured answers are
discarded. Agents see an empty message and outbound webhooks cannot
access the submitted data.

This change preserves the Flow response in message content attributes
and renders a readable response card in the conversation. Existing
WhatsApp messages and other interactive message types remain unchanged.

Fixes https://github.com/chatwoot/chatwoot/issues/13970


<img width="1234" height="1350" alt="CleanShot 2026-08-04 at 00 40
14@2x"
src="https://github.com/user-attachments/assets/884b3a5c-bab3-4196-8037-776e6c41ab2a"
/>


### How to reproduce

1. Send an approved WhatsApp template containing a Flow button.
2. Complete and submit the Flow from WhatsApp.
3. Open the conversation in Chatwoot.
4. Observe that the incoming message has no visible content and the
submitted fields are absent from the message webhook.

### What changed

- Parse `interactive.nfm_reply.response_json` for incoming WhatsApp
Cloud messages.
- Store the Flow name, body, and structured response under
`content_attributes.whatsapp_flow_response`.
- Add a dedicated conversation bubble that formats submitted field names
and values.
- Preserve original response keys while converting message attributes to
the frontend shape.
- Keep `flow_token` available in stored/webhook data while omitting it
from the agent-facing card.
- Add focused backend, helper, and component regression coverage.

### How to test

1. Send a WhatsApp Flow template to a contact.
2. Submit the Flow with multiple field types, including free text and a
selection.
3. Confirm the incoming conversation message displays each submitted
field and value.
4. Confirm an empty-answer Flow still renders a visible “Submitted a
flow response” message.
5. Inspect the `message_created` webhook and confirm the structured
response is present in
`content_attributes.whatsapp_flow_response.response_json`.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-05 14:55:41 +04:00
Sony Mathew
56be133bb0 feat(data-imports): add Freshdesk migration (1/3) (#15261)
## Description

Adds Freshdesk as an integration import source so administrators can
validate a Freshdesk domain and API key, then import contacts, tickets,
public replies, customer replies, and private notes while tracking
progress from Data Imports.

The integration has now been validated against a live Freshdesk trial
tenant with contacts, Web Chat and phone tickets, public replies,
customer replies, a private note, pagination, requester expansion, and
attachment metadata. That validation found and fixed the current Web
Chat source mapping and prevented the ticket description from
duplicating the initial Web Chat message.

Related: #15116

## Closes

Closes
[CW-7639](https://linear.app/chatwoot/issue/CW-7639/freshdesk-freshworks-migration)

## Type of change

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

## What changed

- Added a shared source adapter, importer, job, retry, restart,
creation, and placeholder inbox contract used by Intercom and Freshdesk.
- Added Freshdesk API authentication, contact and ticket pagination,
requester expansion, conversation retrieval, normalization, channel
grouping, and error handling.
- Added current Freshdesk source identifiers through SMS, including Web
Chat source `15`, and grouped equivalent sources into placeholder
inboxes.
- Used Web Chat conversation events as the complete message history so
the generated ticket description does not duplicate the initial customer
message.
- Preserved Freshdesk ticket subjects in source metadata and added a
sanitized live-derived Web Chat fixture with structured bodies and
attachment metadata.
- Added Freshdesk selection, domain and API key validation, and
provider-neutral import status handling in the Data Imports UI.

## How to test

1. Enable the data_import feature for an account and open Settings >
Data > New import.
2. Select Freshdesk and enter a Freshdesk domain and API key.
3. Select contacts and/or conversations, validate the credentials, and
start the import.
4. Confirm progress is displayed and imported tickets appear as resolved
conversations in Freshdesk placeholder inboxes with public replies and
private notes preserved.
5. Verify Web Chat tickets appear in the Chat placeholder inbox and the
initial customer message is imported once.
6. Verify an abandoned import can be restarted and a stalled import can
be retried.

## Current scope

- **Product decision:** Attachment binaries are intentionally not
imported in the current migration scope. Attachment metadata is
preserved and messages include a skipped-attachment marker.
- Adaptive Retry-After scheduling and handling the 30,000-ticket listing
ceiling are covered by stacked follow-up PRs.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-05 16:25:30 +05:30
Vishnu Narayanan
bab2f99004 perf: reduce per-request work in conversation filter endpoint (#15321)
## Description

Every conversation filter request runs three separate unbounded COUNT
queries over the filtered set (mine, unassigned, all) and eager-loads
every message of every conversation on the page. On large accounts this
adds a fixed 1-2s of latency per request regardless of the filter.

This PR trims both:

- The three counts are now computed in a single pass using `COUNT(*)
FILTER (...)` aggregates. Response shape and count semantics are
unchanged.
- The `:messages` eager-load is removed from the filter base relation.
The list payload fetches messages through scoped queries (last message,
last non-activity message, unread messages), which never read the
preloaded collection, so it was loaded and discarded on every request.

Fixes https://linear.app/chatwoot/issue/CW-7830
2026-08-05 14:43:51 +05:30
Vishnu Narayanan
d83135721b fix: clear conversation list loading state when filter request fails (#15320)
## 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
2026-08-05 14:43:16 +05:30
Vishnu Narayanan
d01ad1fc97 fix: handle ActionController::Parameters in days_before filter (#15319)
## Description

The `days_before` filter operator in
`FilterService#days_before_filter_query` calls `with_indifferent_access`
directly on the query hash. In production the filter payload arrives as
`ActionController::Parameters` (the controller passes `params.permit!`),
which does not respond to `with_indifferent_access`, so every
conversation filter request using `days_before` raises `NoMethodError`
and returns a 500.

Existing specs pass plain hashes with `with_indifferent_access`, which
is why this was never caught. This PR normalizes the query hash via
`to_h` first (permitted parameters convert to a
`HashWithIndifferentAccess`, plain hashes are unaffected) and adds a
regression spec that builds the payload as
`ActionController::Parameters`, mirroring the controller.

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

## Type of change

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

## How Has This Been Tested?

- Added a regression spec that passes the filter payload as permitted
`ActionController::Parameters` with the `days_before` operator. It
reproduces the `NoMethodError` on the current develop branch and passes
with this change.
- `bundle exec rspec spec/services/conversations/filter_service_spec.rb`
(31 examples, 0 failures)
- `bundle exec rspec
spec/services/conversations/filter_service_frontend_alignment_spec.rb`
(10 examples, 0 failures)

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-05 14:43:05 +05:30
Vishnu Narayanan
c82b75dcce feat: add manage-notification-preferences footer to agent notification emails (#15192)
## Description

Agent notification emails (new conversation, assignment, mention, new
message, SLA misses) currently carry no indication of why the recipient
received them or how to turn them off. When recipients cannot easily
manage these emails, some mark them as spam, which hurts sender
reputation and overall deliverability.

This adds a short footer line to agent notification emails only:

> You're receiving this email because email notifications are enabled
for your account. **Manage notification preferences**.

The link points the recipient to their dashboard profile notification
settings page (`/app/accounts/:account_id/profile/settings`) so they can
disable notifications instead of marking the email as spam. This is a
navigational link only, not a one-click unsubscribe (a real unsubscribe
flow is a separate future change).

Scoping: the mailer layout (`app/views/layouts/mailer/base.liquid`) is
shared by all mailers via `ApplicationMailer`. To keep the footer on
agent notification emails only,
`AgentNotifications::ConversationNotificationsMailer` exposes a
`notification_settings_url` liquid local (built from the existing
`app_account_url` route helper), and the layout renders the footer line
only when that local is present. Transactional and other emails do not
set it, so they are unaffected. The enterprise SLA notification methods
prepend into the same mailer class, so they inherit the footer
automatically.

Part of https://linear.app/chatwoot/issue/CW-7752

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec
spec/mailers/agent_notifications/conversation_notifications_mailer_spec.rb
spec/mailers/confirmation_instructions_spec.rb` — 26 examples, 0
failures. Asserts the footer link and settings URL are present in an
agent notification email and absent from a non-notification
(confirmation) email.
- `bundle exec rspec
spec/enterprise/mailers/enterprise/agent_notifications/conversation_notifications_mailer_spec.rb`
— 6 examples, 0 failures (SLA notification emails).

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-05 14:31:28 +05:30
Aakash Bakhle
6f6ddc5636 fix: flaky AppliedSla scope spec after Rails reloads (#15327)
CI no longer fails when the AppliedSla scope spec runs after Rails
reloads model classes. The spec now compares record IDs, so it checks
which SLA records the scope returns without depending on Ruby class
identity.

## How to reproduce

Run the report builder and assignment policy controller specs before
`spec/enterprise/models/applied_sla_spec.rb` in the same RSpec process.
Before the fix, the scope returns records with the expected IDs, but the
assertion rejects them because FactoryBot and Rails use different
`AppliedSla` class objects after the reload.

## What changed

The spec reads the IDs returned by `with_sla_applicable_conversation`
and checks the normal conversation, missing contact, and blocked contact
cases with those IDs.

The affected CircleCI shard passes locally after the change.
2026-08-05 14:07:04 +05:30
Kesh
d1a6ea5798 fix: normalize mexico whatsapp phone numbers (#14174)
## Summary

This fixes WhatsApp phone number normalization for Mexico numbers when
using Twilio as a provider.

Twilio may send incoming Mexico WhatsApp phone numbers with an extra `1`
after the country code, for example:

- stored contact inbox source: `whatsapp:+525512345678`
- incoming message source: `whatsapp:+5215512345678`

Before this change, Chatwoot could treat these as different source IDs
and create a duplicate contact/conversation path instead of matching the
existing one.

## What changed

- added a Mexico-specific phone normalizer
- normalize `+521...` to `+52...` for contact matching
- kept the original incoming source ID when creating a brand new contact
inbox

## Why this approach

Chatwoot already has country-specific normalization for WhatsApp numbers
in the normalization service. This follows the same pattern and keeps
the change small and isolated.

## Tests

Added specs covering:

- matching an existing Mexico WhatsApp contact inbox when Twilio sends
`+521...`
- preserving the original source ID when no matching contact inbox
exists and a new one is created

Fixes
https://linear.app/chatwoot/issue/CW-6844/fix-normalize-mexico-whatsapp-phone-numbers-on-twilio-to-prevent

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-05 12:12:01 +04:00
Aakash Bakhle
342f0a399c fix(captain): resolve V2 FAQ citations from trusted sources (#15159)
Captain V2 now adds FAQ citations from a structured model response. The
model returns ordered response parts with citation indexes, and Chatwoot
turns only trusted indexes into customer links.

## Before

Captain V2 asked the model to copy text markers such as `[[faq:1]]`.
Chatwoot used one regular expression to replace those markers with links
in the outgoing message and another regular expression to remove the
rendered links before the next model turn. Long conversations depended
on parsing the customer message to recover plain model context.

## After

The FAQ lookup tool now gives each eligible source document a numeric
index and never gives the model a URL. FAQ results from the same
document reuse the same index. The model returns `response_parts`, where
each part contains customer text and the supporting citation indexes.
Chatwoot checks every index against the document IDs registered during
the current run.

Only stored HTTP or HTTPS web-document links without embedded
credentials can appear in the customer reply. Blank links, PDF sources,
attachments, non-HTTP storage links, and unknown indexes do not create
links. Sources receive display numbers in the order they first appear,
and repeated sources keep the same display number.

Chatwoot saves the structured response parts with each newly generated
Captain message. Later Captain V2 turns use the saved plain text for
those messages, so they never need to parse rendered links. Existing
messages remain unchanged and continue to use their stored content. When
citations are disabled, Chatwoot clears citation indexes before it
returns or saves the response.

Captain V1, Copilot, legacy prompts, legacy tools, and the playground
response contract are unchanged. The playground continues to show the
plain `response` field.

## Closes

[AI-138](https://linear.app/chatwoot/issue/AI-138/faq-citation-fix)

## How to test

1. Open a conversation handled by a Captain V2 assistant and turn
citations off. Ask a greeting, an FAQ question, a code question, and a
follow up question. Confirm that the assistant answers normally and
shows no source links.
2. Turn citations on and ask a question that matches one public web
document. Confirm that the reply shows the stored public link after the
supported text.
3. Ask a question that needs two public web documents. Confirm that the
response order stays correct, each link appears after the supported
text, and repeated sources keep the same display number.
4. Ask a question that retrieves several FAQ results from one document.
Confirm that the reply shows the document once at each supported
response part rather than exposing separate FAQ sources.
5. Ask a question supported by a PDF, attachment, blank link, or
non-HTTP storage link. Confirm that Captain can use the information but
does not show a customer link.
6. Ask for a fenced code example with a citation. Confirm that the code
block stays complete and the citation appears after the closing fence.
7. Continue the conversation with a follow up question. Confirm that
Captain uses the earlier plain response text and does not receive or
repeat rendered citation links.
8. Test a scenario handoff in a conversation. Confirm that the handoff
and final response still work.
2026-08-05 10:29:43 +05:30
Sivin Varghese
9177fffa71 fix: enforce required conversation attributes on resolve for macros (#15232) 2026-08-04 19:59:05 +05:30
Tanmay Deep Sharma
8448001fdc fix(security): gate SLA APIs and automation on the sla feature (#15209)
SLA is a premium feature, but several SLA surfaces never checked the
account's `sla` flag. An account without SLA — or one whose plan was
downgraded and had the flag revoked — could still read SLA breach
reporting, attach an SLA policy to a conversation through the
conversation update endpoint, and keep applying SLA policies from
automation rules. All three paths now require the feature, matching the
SLA policies API which already checked it.

## How to reproduce

1. Disable the `sla` feature for an account that has SLA policies and
applied SLAs.
2. `GET /api/v1/accounts/{id}/applied_slas/metrics` (also `index` and
`download`) — returns the account's SLA breach data instead of denying.
3. `PATCH /api/v1/accounts/{id}/conversations/{display_id}` with
`sla_policy_id` — the policy is attached.
4. Trigger an automation rule with an "Add SLA" action — the SLA is
applied and starts tracking.

## What changed

- `Api::V1::Accounts::AppliedSlasController` gains the same
`ensure_sla_feature_enabled` guard the SLA policies controller uses,
covering `index`, `metrics` and `download`.
-
`Enterprise::Api::V1::Accounts::ConversationsController#permitted_update_params`
only permits `sla_policy_id` when the feature is enabled. When it is off
the parameter is dropped, so an existing SLA association is preserved
rather than cleared.
- `Enterprise::ActionService#add_sla` returns early when the feature is
off, so automation rules stop applying SLAs on revoked accounts.
2026-08-04 18:43:34 +05:30
Aakash Bakhle
e7ded47753 feat: move Captain auto-resolve policy to assistants (1/5) (#15299)
Captain auto-resolve policy is now owned by each assistant. Existing
assistants first read an assistant-level setting, fall back to the
current account setting during rollout, and are backfilled
asynchronously so behavior is preserved.

## Closes

[AI-163](https://linear.app/chatwoot/issue/AI-163)

## How to test

1. Configure different auto-resolve modes on two assistants in the same
account through the API.
2. Confirm each assistant follows its own mode when the inactivity job
runs.
3. Confirm an assistant without the new setting follows the existing
account mode.
4. Run the migration job and confirm the account mode is copied without
overwriting an existing assistant mode.
5. Confirm evaluated mode falls back to time-based resolution when the
`captain_tasks` capability is unavailable.

## What changed

- Added assistant-level `disabled`, `legacy`, and `evaluated` policy
storage and validation.
- Updated scheduling and resolution runtime reads to use the assistant
policy.
- Added a compatibility fallback and asynchronous backfill from the
account setting.
- Preserved the account-level `captain_tasks` capability gate for
evaluation.
- Kept this foundation independent of the Captain V2 guard; the timer
and advanced settings in stacks 2–5 are Captain V2-only.

Stack 1 of 5. This is the base for #15303.
2026-08-04 18:04:53 +05:30
Tanmay Deep Sharma
0ad2780972 fix(conversations): prevent duplicate assignment activity from concurrent assignment writes (#15275)
Conversations could get reassigned repeatedly in quick succession —
bouncing between agents with several "Assigned to X" activity messages
in a row — whenever an assignment write raced against another assignment
write on the same conversation (an automation rule vs. Assignment V2's
auto-assign, two manual assignment clicks, two concurrent status
transitions triggering legacy round-robin, etc). This was reproducible
with or without Assignment V2 enabled; it wasn't a V2-specific issue,
just more visible under message bursts. Assignment V2's own job-vs-job
race was already fixed separately (#14495) — this PR covers the other
writers that don't check the conversation's current state before
overwriting it.

## How to reproduce

Configure an automation rule that assigns an agent/team on
`message_created`, then send a burst of messages on one conversation
from a channel/inbox with concurrent message delivery (or race it
against another assignment source — a manual assignment click, or a
status change that triggers legacy auto-assignment). The conversation's
assignee flips between agents multiple times, each producing its own
activity message, even though later writes were often redundant (setting
the assignee to a value it already had, from the writer's stale point of
view).

## What changed

Three assignment write paths now take a row lock (`with_lock`) before
writing, so a concurrent writer re-reads the conversation's true current
state before deciding whether a write is actually needed:

- `ActionService#assign_agent`/`#assign_team` (and the corresponding
unassign methods) — used by automation rules, macros, and delayed
automations.
- `Conversations::AssignmentService#assign_agent`/`#assign_agent_bot` —
the dashboard's assignee dropdown (human agent or AgentBot).
- `AutoAssignment::AgentAssignmentService#perform` — the legacy (non-V2)
round-robin auto-assignment path.

A write that would just reapply an already-current value becomes a
genuine no-op instead of producing a redundant `UPDATE` and duplicate
activity message. Legitimate reassignment (a real change in target, or
reassignment away from an agent who lost inbox access) is unaffected.
2026-08-04 17:08:42 +05:30
Muhsin Keloth
950d871830 fix(meta): add independent incident runtime controls (#15318)
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>
2026-08-04 15:13:59 +04:00
Muhsin Keloth
544f0da63c feat(whatsapp): add template listing to settings (#15312)
Account administrators can now browse synced WhatsApp message templates
inside Settings without opening a provider dashboard. The page combines
native WhatsApp and Twilio WhatsApp templates, with channel and language
filters, search, a template preview drawer, and links to manage
templates at the provider.

Creating, updating, and deleting templates in Chatwoot remains out of
scope for this first version.

Related:
https://linear.app/chatwoot/issue/PLA-193/add-whatsapp-template-listing-to-account-settings

<img width="2742" height="1460" alt="CleanShot 2026-08-03 at 20 23
27@2x"
src="https://github.com/user-attachments/assets/5d0b7a98-f882-425a-b9ec-7d631371959c"
/>

### Things to know

- This is stack 2 of 2 and depends on API draft #15311.
- Review this PR against `codex/whatsapp-template-api`; after the API PR
lands, this branch can be rebased and retargeted to `develop`.
- Both native WhatsApp and Twilio WhatsApp template caches are
supported.
- Template management remains in Meta Business Portfolio or Twilio
Console.

### How to test

1. Open Settings → WhatsApp templates for an account with native and
Twilio WhatsApp inboxes.
2. Confirm templates from each inbox are listed with their status,
language, category, channel, and last-updated metadata.
3. Filter by channel and language, and search by template name or
content.
4. Open a template and confirm its preview renders in the drawer.
5. Use the provider action and confirm it opens the appropriate Meta or
Twilio template-management page.
6. Confirm loading, empty, and error states remain usable.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-08-04 15:09:26 +04:00
Shivam Mishra
94e9727eb4 feat: move Captain conversation outcomes to episode grain [CW-7792] (#15315)
Moves Captain conversation outcomes from one row per conversation to one
row per **engagement episode**: a new row each time demand for Captain
(re)starts - first eligible message, a reopen after resolution, or
(reserved) explicit assignment. Each episode has its own demand anchor,
window, and trigger, so returning customers count as new demand and
later cycles can't overwrite an earlier episode's handoff reason,
resolution, or CSAT. Also adds `conversation_outcomes` associations on
Account, Inbox, Conversation, and Captain::Assistant.

## Why this wasn't in #15233

The episode design came out of reviewing the wiring PR: per-conversation
grain couldn't answer per-cycle questions without a patch per field. The
table is unreleased with no writers, so changing the grain now is a pure
schema swap - and landing it first means the tracker gets reviewed
against the final model.

## What changed

- Adds `episode_trigger`, `started_at`, `ended_at`
- Drops `reopen_count` and `last_reopened_at` - reopens are episode rows
now
- Uniqueness moves from `(account, assistant, conversation)` to
per-boundary `(account, conversation, started_at)`
- Two partial unique indexes: one open episode per conversation, one
initial episode per stream
- Model: trigger enum, `started_at` uniqueness validation,
`chronological`/`covering` scopes
2026-08-04 15:58:28 +05:30
Muhsin Keloth
1c28df49e5 feat(whatsapp): expose cached Twilio templates (#15311)
The cached inbox template endpoint currently serves only native WhatsApp
channels. This draft extends the same read-only endpoint to Twilio
WhatsApp inboxes and adds last-sync metadata for both providers, so the
template listing can use one stable contract without making provider
requests.

Non-WhatsApp inbox behavior remains unchanged.

Related:
https://linear.app/chatwoot/issue/PLA-193/add-whatsapp-template-listing-to-account-settings

### Things to know

- This is stack 1 of 2 and contains only the API contract needed by the
settings UI in #15312.
- Template data remains cache-only; the endpoint does not call Meta or
Twilio.
- Native WhatsApp templates are filtered by `name`; Twilio Content
Templates are filtered by `friendly_name`.

### How to test

1. Request the message templates endpoint for a native WhatsApp inbox
and confirm it returns the cached templates plus `meta.last_updated_at`.
2. Request it for a Twilio WhatsApp inbox and confirm it returns cached
Content Templates plus `meta.last_updated_at`.
3. Pass a template name and confirm only the matching provider template
is returned.
4. Request it for a non-WhatsApp inbox and confirm the endpoint returns
an unprocessable entity response.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-04 14:13:11 +04:00
Sivin Varghese
80c9357387 chore: improve slash menu behavior inside table cells (#15305) 2026-08-04 12:18:34 +05:30
Afiq
e3ce2772fe fix: preserve original filenames for Telegram inbound attachments (#15071)
## Description

Telegram inbound attachments were stored under a generic, renamed
filename (e.g. `file_5.pdf`) instead of the name the sender actually
uploaded (e.g. `Quarterly-Report-2025.pdf`).

**Root cause:** In `Telegram::IncomingMessageService#attach_files`, the
attachment filename was taken from the `Down`-downloaded file's
`original_filename`, which `Down` derives from the Telegram file-server
download URL. That URL is built from Telegram's `getFile` response,
whose `file_path` is Telegram's *internal* storage path — not the
sender's chosen name. The real name is present in the webhook payload as
`file_name` on the `document` / `audio` / `video` object, but it was
never read.

Because `Attachment#set_extension` derives the stored `extension` from
the filename, this also corrupted the recorded extension for documents
whose Telegram path lacked a proper one.

**Fix:** Prefer the payload's `file_name`, falling back to the
downloaded name when it's absent (photos, stickers, voice notes — which
Telegram sends without a `file_name`). This mirrors how the Line channel
(`message['fileName']`) and WhatsApp Cloud channel already handle
inbound attachment filenames.

```ruby
filename: file[:file_name].presence || attachment_file.original_filename,
```

Fixes #15070

## Type of change

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

## How Has This Been Tested?

Added/updated specs in
`spec/services/telegram/incoming_message_service_spec.rb`:

- Extended the existing document-message example to assert the stored
attachment retains the original payload filename (`Screenshot 2021-09-27
at 2.01.14 PM.png`) rather than Telegram's internal download name.
- Added a fallback example (a `photo` payload, which carries no
`file_name`) asserting the attachment is still created and the filename
falls back to the downloaded name — guarding the `.presence || …` branch
and confirming no regression for media types without a `file_name`.

> Note: my local machine does not have the Ruby/Redis toolchain to run
the suite, so I have not run RSpec/RuboCop locally — I'm relying on CI
to validate. Suggested command for reviewers: `bundle exec rspec
spec/services/telegram/incoming_message_service_spec.rb`.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have added tests that prove my fix is effective
- [ ] New and existing unit tests pass locally with my changes (unable
to run locally — see note above; relying on CI)
2026-08-03 19:29:43 -07:00
Vishnu Narayanan
3633dd44fb perf: keep planner off misestimated inbox scan for label filter queries (#15264)
## Description

Label-based conversation filters (custom views / folders) can time out
for non-admin agents on large accounts. The permission scoping adds
`conversations.inbox_id IN (subquery)` to the filter query, and Postgres
misestimates the row count for the account+inbox combination by several
orders of magnitude. It then drives the query through an inbox index
scan over the account's entire conversation set instead of starting from
the label's taggings, which are often only a handful of rows. The query
exceeds the request timeout and the folder never loads.

This change adds a planner hint to
`Conversations::PermissionFilterService`: when enabled, the inbox
scoping condition is written as `(conversations.inbox_id + 0) IN
(subquery)`, which returns identical rows but is not indexable, so the
planner cannot choose the misestimated path.
`Conversations::FilterService` enables the hint only when the filter
payload contains a `labels` condition. All other callers of the
permission service are unchanged, since the bare condition is the right
plan for unfiltered conversation lists.

Observed on a ~365k-conversation account (worst case, cold cache): label
filter for an agent went from exceeding 60s to ~2ms. Popular labels
(~154k taggings) show no regression. Admin queries are untouched.

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

## Type of change

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

## How Has This Been Tested?

- Specs for row-equivalence of the hinted scoping, admin behavior, and
hint application scoped to label filters only
- `EXPLAIN (ANALYZE, BUFFERS)` comparison on a production-scale dataset
for rare-label, popular-label, and no-label query shapes, agent and
admin, with generic plans matching prepared-statement behavior
2026-08-03 19:50:02 +05:30
Amix
57524ebb7d ci: add Brakeman and bundle-audit security scan job (#15169) 2026-08-03 18:47:00 +05:30
Shivam Mishra
871130f566 feat: add Captain conversation outcome model [CW-7792] (#15233)
Adds the `Captain::ConversationOutcome` model and its table: one row per
(account, assistant, conversation) that folds Captain lifecycle events
into flat facts for the upcoming value metrics report. This is the
second PR in the outcomes stack, on top of the lifecycle event layer
(#15213); the tracker and listener that populate it come next.

The table deliberately stores only timestamps, counts, and the handoff
reason. Every classification and duration (coverage, autonomous vs
assisted, durable resolution, resolution time) is derived at query time
in the stats layer, so definitions can change later without backfills.
The row is created on the first qualifying customer message, making
`created_at` the demand-start anchor. Commit messages carry the
field-by-field rationale for what was left out.

| Field | Meaning |
| --- | --- |
| `account_id`, `assistant_id`, `conversation_id`, `inbox_id` |
Reporting dimensions; unique on the first three |
| `first_captain_reply_at` / `last_captain_reply_at` | First and latest
public Captain reply |
| `captain_reply_count` | Public Captain replies in the conversation |
| `first_human_reply_at` | First public human agent reply, used to
classify assisted resolutions |
| `handoff_at` | When Captain handed the conversation to a human |
| `handoff_reason_category` | Why it handed off (customer_request,
missing_knowledge, unsupported_request, policy_restriction,
tool_failure, pending_clarification, usage_limit) |
| `resolved_at` | When the conversation was resolved |
| `last_reopened_at` / `reopen_count` | Reopen facts backing reopen rate
and durable-resolution checks |
| `csat_rating` / `csat_received_at` | CSAT for Captain-involved
conversations |
2026-08-03 16:23:51 +05:30
Shivam Mishra
eb375c45ce fix(spec): i18n assertion failing for full editor spec (#15298)
Fixes the three `FullEditor.spec.js` slash-menu failures that appeared
after #15291 and #15296 crossed on develop: #15296 added slash-menu
specs that assert real translated labels ("Divider", "Heading 1"), while
#15291 emptied the global i18n catalogue in the vitest setup, so `t()`
started returning raw keys like `SLASH_COMMANDS.DIVIDER`.

The spec now calls `withFullI18n()` from the `test-i18n` helper
introduced in #15291, opting into the full message catalogue the same
way `MacroProperties.spec.js` does. Test-only change, no production code
touched.


Related: https://github.com/chatwoot/chatwoot/pull/15291
2026-08-03 15:36:25 +05:30
Sivin Varghese
bc2cebd255 fix: encode contact search params so emails with a plus sign match (#15295) 2026-08-03 15:09:59 +05:30