4956 Commits

Author SHA1 Message Date
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
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
Rian Polonini
b126621b3b perf: load i18n messages on demand in vitest setup (#15291)
## Description

`vitest.setup.js` runs for every spec file and builds the i18n instance
from the full message catalogue, so each of the 389 spec files resolves
and transforms the 2537 JSON files under
`app/javascript/dashboard/i18n/locale/`. With `pool: 'threads'`, that
cost is paid per worker. The result is that `setup` takes about **60x
longer than the tests themselves**.

Only one spec in the suite asserts on translated copy. This PR leaves
the global i18n instance without messages and adds `withFullI18n`, an
opt-in helper for the specs that need the real catalogue.

**On this repository's own CI, `setup` drops from ~629s to ~71s (-89%)
and total Vitest duration from ~388s to ~196s (-49%).**

This is a performance and testability change, not a cosmetic one. It
touches only how the test harness loads messages — **no translatable
string and no locale file is modified**, so nothing changes for Crowdin
contributors.

Worth noting: `vitest.config.ts` already excludes `**/i18n/**/*` from
coverage, so loading the catalogue in every spec was not serving any
metric.

Fixes #15290

## Type of change

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

## How Has This Been Tested?

### On this repository's CI

The numbers below come from the `test` job of `frontend-fe.yml` on
GitHub Actions — this repo's own runners, not a local machine. Baseline
is **60 successful `develop` runs** (23–31 Jul 2026), parsed from the
Vitest summary line in each job log.

| Vitest metric | `develop` (n=60) | This PR | Delta |
|---|---|---|---|
| **setup** | median **629s** (min 329 / p25 607 / p75 641 / max 680) |
**71s** | **-559s (-89%)** |
| **duration** | median **388s** (min 205 / p25 371 / p75 393 / max 418)
| **196s** | **-192s (-49%)** |

None of the 60 `develop` runs beat this PR on either metric.

Same commit, both on this repo's CI:

```
develop @ bc7ae88   Test Files 389 passed (389)
                    Duration 323.35s (transform 13.75s, setup 522.48s, collect 42.53s,
                                      tests 9.02s, environment 187.05s, prepare 32.77s)

this PR             Test Files 389 passed (389)
                    Duration 196.20s (transform 18.18s, setup 70.67s, collect 65.97s,
                                      tests 12.22s, environment 251.28s, prepare 43.90s)
```

`setup` is the metric that isolates this change: it is a sum of work, so
it is not distorted by how fast a given runner happens to be. `duration`
improves less because it also covers transform, collect and environment,
which this change does not touch.

One caveat if you compare total job times instead: on this PR's run,
`ruby/setup-ruby` took 130s versus 11s on the baseline (an unrelated
cache miss), which hides most of the gain at job level.

### Locally

`pnpm exec vitest run`, twice per scenario, on `develop` at `bc7ae88`
(Node 24.18.1, macOS arm64):

| | setup | duration |
|---|---|---|
| before | 373s / 412s | 60.8s / 67.8s |
| after | 40.5s / 42.8s | 28.1s / 28.5s |

Identical pass/fail counts before and after. Locally there is one
failure in both scenarios, pre-existing on `develop` and unrelated to
this change: `useReportMetrics.spec.js` expects `'5,000'` and receives
`'5.000'`, a thousands-separator difference that depends on the machine
locale. It does not occur on CI, where the suite is fully green
(389/389).

`MacroProperties.spec.js` was the only spec that depended on the global
catalogue — it asserts the real copy from `macros.json`. It now calls
`withFullI18n()` and keeps asserting the same strings, so coverage of
that copy is preserved.

## Notes for reviewers

- The `test-i18n` alias was added to `vitest.config.ts` rather than to
`vite.shared.ts`, to keep it out of the production build.
- `missingWarn: false` and `fallbackWarn: false` were added to the
global instance so specs that render translated components without
opting in do not flood the output with missing-key warnings.
- Any future spec that needs the real copy just calls `withFullI18n()`
at the top of the file.

## 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
- [ ] I have added tests that prove my fix is effective or that my
feature works (no new test: this is a performance change, verified by
the CI measurements above and by the unchanged pass/fail counts)
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-08-03 15:08:01 +05:30
Sivin Varghese
f137bf47f2 chore: add divider to the article editor slash menu (#15296) 2026-08-03 14:51:17 +05:30
Muhsin Keloth
7142bc43a5 feat(whatsapp): expose message templates through the API (#15277)
External integrations can send WhatsApp template messages, but they do
not have a focused API to discover the templates available for an inbox.
This adds an authenticated endpoint that lists the inbox's cached
WhatsApp templates and optionally filters them by exact template name.

Existing inbox responses and template synchronization behavior remain
unchanged.

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

### Things to know

- The endpoint returns templates already synchronized and cached for a
direct WhatsApp inbox; it does not make a new provider request.
- Non-WhatsApp inboxes return an unprocessable entity response.

### How to test

1. Authenticate as a user assigned to a WhatsApp inbox.
2. Request `GET
/api/v1/accounts/:account_id/inboxes/:id/message_templates` and verify
the response contains the inbox's templates under `payload`.
3. Repeat the request with `?name=<template_name>` and verify only the
exact matching template is returned.
4. Request the endpoint for a non-WhatsApp inbox and verify it returns
an unprocessable entity response.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-03 12:55:13 +04:00
Muhsin Keloth
e1270a4ef8 fix(whatsapp): render template values in conversation transcript (#15255)
WhatsApp template messages can be delivered with the correct variable
values while the Chatwoot conversation shows numeric placeholders
instead. This affects template sends where the client submits the raw
template body together with valid processed parameters; WhatsApp
delivery itself remains unchanged.

The message model currently passes outgoing content through Liquid
before saving it. Liquid interprets positional WhatsApp placeholders
such as `{{1}}` and `{{2}}` as numeric expressions, even though the
WhatsApp sender independently uses `processed_params.body` to build the
provider payload.


Fixes
https://linear.app/chatwoot/issue/PLA-192/render-whatsapp-template-values-in-conversation-transcripts

### What changed

For WhatsApp template messages, Chatwoot now substitutes positional or
named body placeholders from `processed_params.body` before saving the
transcript. Missing values remain visible as placeholders, and ordinary
outgoing Liquid messages continue through the existing rendering path.

The existing message model regression suite remains green, and the
reported payload now persists as `Hello Ahmad, Furqan is your contact.`

### Things to know

This corrects newly created messages. Existing conversation messages
that were already stored as `1`, `2`, and so on are not backfilled.

### How to reproduce

1. Open a WhatsApp conversation outside the 24-hour messaging window.
2. Send a positional template whose body contains `{{1}}` and `{{2}}`.
3. Supply values for both parameters while submitting the original
template body as the message content.
4. Observe that WhatsApp delivers the substituted values, while the
Chatwoot transcript shows `1` and `2`.

### How to test

1. Select a WhatsApp template with two body variables.
2. Enter `Ahmad` and `Furqan` as the values and send the message.
3. Confirm the recipient receives the substituted template.
4. Confirm the Chatwoot conversation also displays `Ahmad` and `Furqan`
instead of the numeric placeholders.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-03 12:31:06 +04:00
Sivin Varghese
6347ad1926 feat: add analytics providers to help center (#15124) 2026-08-03 10:39:02 +05:30
Shivam Mishra
bc7ae88d5e feat: add generic side drawer component [CW-7757] (#15188)
This adds a reusable side drawer component
(`components-next/drawer/Drawer.vue`), similar in spirit to Dialog and
Popover, so drawers across the app share one implementation. The drawer
renders as a floating card anchored to the inline-end edge with a
slide-in/slide-out animation, and handles the backdrop, Escape key,
click-outside, and focus restore. The layout inside is fully
slot-driven, with a `close` function passed through the slot.

The Captain overview and report drilldown drawers now use this
component, and the Captain document details view moved from a centered
dialog to this drawer.

## Closes

CW-7757

## What changed

- New `Drawer.vue`: teleported floating card with backdrop, RTL-aware
slide transition, focus management, and a default slot receiving
`close`; emits `afterLeave` so consumers mounted with `v-if` can unmount
after the exit animation
- `AssistantDrilldownDrawer` and `ReportDrilldownDrawer` refactored to
consume it, keeping their own headers and content
- `DocumentDetails` migrated from Dialog to the drawer, dropping the
imperative `dialogRef.open()` plumbing and fixed-height inner scroll
areas
- Shared `DRAWER.CLOSE` i18n key replaces the per-drawer close labels



https://github.com/user-attachments/assets/58db6c34-8b6f-46e2-bdb1-f2d3675f1a94

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-31 15:02:59 +05:30
Shivam Mishra
7981e2cc75 refactor: introduce normalized Captain lifecycle events [CW-7792] (#15213)
This introduces a small event layer for the Captain V2 conversation
lifecycle. A new `Captain::ConversationEvents` facade dispatches five
normalized events (`captain.conversation.engaged`,
`captain.conversation.handed_off`, `captain.conversation.resolved`,
`captain.response.completed`, `captain.response.failed`) from the points
where Captain engages a conversation, replies, fails, hands off, or
auto-resolves. Each event carries the conversation, assistant,
timestamp, and a `source`/`reason_category` where relevant.

## Why this, why now

The Captain V2 flow is about to gain several observers at once:
conversation outcome tracking, agent session capture, and analytics all
need to know when Captain engages, replies, fails, hands off, or
resolves. Wiring each of them directly into `ResponseBuilderJob`,
`HookExecutionService`, and the tools would tangle secondary bookkeeping
into the paths that deliver customer-facing behavior, and every future
consumer would deepen that. Landing the event layer first as its own PR
means the flow announces these moments once and stays otherwise
untouched: customer-visible behavior (messages, status changes,
handoffs, usage enforcement) remains synchronous, while secondary
effects subscribe through listeners. The upcoming conversation outcomes
PR then reduces to a listener plus a model instead of another round of
edits to the core flow, which is why this ships now, before that work
merges.

The existing inference reporting behavior is folded into this layer: the
`conversation.captain_inference_*` events and their dispatch helpers on
`Enterprise::Conversation` are removed, and a dedicated
`Captain::ReportingEventListener` (registered on the enterprise async
dispatcher) maps `source: 'inference'` events to the same stored
reporting event names, so recorded analytics and the assistant stats
builder are unaffected.

## What changed
- New `Captain::ConversationEvents` facade and event type constants
- Event emission from `HookExecutionService` (engagement, usage-limit
handoff), `ResponseBuilderJob` (response completed/failed,
generation-failure handoff), `HandoffTool` (tool handoff), and
`InboxPendingConversationsResolutionJob` (inference resolved/handoff)
- A dedicated `Captain::ReportingEventListener` preserves inference
reporting events through the new event names, removing captain logic
from the OSS listener
2026-07-31 13:55:57 +05:30
Khush Raghav Nanda
d82c71e78a fix(conversation): remove deprecated style block (#15179) 2026-07-31 12:10:41 +05:30
Amix
beffabeee2 chore: remove stale FIXME about referer attribute typo (#14952)
## Summary
`Api::V1::Widget::BaseController#conversation_params` had a `FIXME: typo
referrer in additional attributes, will probably require a migration`
comment. On investigation, `additional_attributes[:referer]` is used
consistently across the whole codebase — automation rules,
conversation/contact filters, `ConversationInfo.vue`, and the widget
SDK's `referer_url` param — matching the historical (if misspelled) HTTP
`Referer` header convention. There is no actual inconsistency to fix,
and renaming the key would require a data migration and would break
existing customer automations built on the `referer` key, for no
functional benefit.

## What changed
- Removed the misleading FIXME comment. No behavior change.
2026-07-30 20:10:28 -07:00
Nameless-Monster-Nerd
b7a0ec62ba fix(widget): validate required checkboxes as accepted (#15135)
Required checkbox fields in the pre-chat form now remain invalid unless
they are checked. Other required field types keep their existing
validation behavior, and the existing localized required message is
reused.

## Closes

Closes https://github.com/chatwoot/chatwoot/issues/15128

## How to reproduce

1. Add a required checkbox custom attribute to the pre-chat form.
2. Check and then uncheck it.
3. Submit the form; submission is now blocked until the checkbox is
checked.

## What changed

- Use the FormKit accepted rule for required checkbox fields.
- Map accepted validation failures to the existing pre-chat required
message.

---------

Co-authored-by: Nazmus Samir <nazmussamir@Nazmuss-MacBook-Pro.local>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-30 19:50:09 -07:00
Vaibhav Dewangan
ecb7a44f07 fix(email): resolve reply recipients from the message being sent (#15194)
When an agent loops a new address into an ongoing email thread and then
adds a private note, the reply currently goes out with no Cc, and with
To falling back to the conversation contact. The newly added person
never receives the mail. This change makes an outgoing email keep the
To/Cc/Bcc that were entered on that reply, no matter what is added to
the conversation afterwards.

Closes #15193

## How to reproduce

1. Open a conversation on an Email inbox.
2. Add a new address to **Cc** and send a reply.
3. Immediately add a private note to the same conversation.
4. Inspect the delivered mail: `Cc` is empty and `To` is the
conversation contact instead of the addresses entered on the reply.

## What changed

`ConversationReplyMailer#cc_bcc_emails` and
`#to_emails_from_content_attributes` read the addresses from
`@conversation.messages.outgoing.last` rather than from the message they
were handed. Replies are delivered asynchronously
(`SendReplyJob.perform_later`, and `wait: 2.seconds` when the message
has attachments), so any outgoing message created in that window
replaces the recipients of a mail that is already queued. A private note
is the easiest way to hit it: private notes are outgoing messages, and
on an Email inbox `Messages::MessageBuilder#process_emails` stores them
with empty `to_emails` / `cc_emails` / `bcc_emails`.

Both lookups now go through the existing `current_message` helper
(`@message || @conversation.messages.outgoing.last`), which the `from`
and `reply_to` builders already use. `email_reply` sets `@message`, so
it resolves the recipients of the message being delivered;
`reply_with_summary` and `reply_without_summary` leave `@message` nil
and keep their current behaviour.

Added a spec covering the private-note case in
`spec/mailers/conversation_reply_mailer_spec.rb`.

## Note

I could not run the Ruby test suite locally — there is no Ruby toolchain
in the environment I worked in, so the new spec has not been executed on
my side. The change and the spec were verified by reading the code paths
(`Message#send_reply`, `Email::SendOnEmailService`,
`Messages::MessageBuilder#process_emails`,
`ConversationReplyMailerHelper#prepare_mail`). Deferring to CI for the
actual run.
2026-07-30 17:53:11 -07:00
Sojan Jose
226af4959e feat: add pending conversation takeover UI (#14876)
Adds a dashboard-only takeover path for pending conversations currently
handled by an assistant/bot. Agents see the warning by default, stay in
private-note mode while the conversation is pending, and can use Take
over to move the conversation back to human handling.

## Related
- Original scope:
https://linear.app/chatwoot/issue/CW-7450/block-replies-and-add-takeover-for-agent-bot-ownership
- Backend follow-up:
https://linear.app/chatwoot/issue/CW-7779/enforce-backend-reply-blocking-for-agent-bot-owned-conversations

## Why
We want to prevent accidental parallel handling from the dashboard while
an assistant is managing a pending conversation, without expanding this
PR into API-level enforcement. Backend blocking is tracked separately in
CW-7779.

## What changed
- Locks the dashboard composer to private-note mode while the
conversation status is `pending`.
- Shows the takeover banner by default for pending conversations.
- Uses the Agent Bot assignee name when the conversation payload exposes
one, otherwise falls back to `a bot`.
- Simplifies the banner action copy to `Take over`.
- Reopens and self-assigns the conversation from the takeover action.
- Clears stale local `AgentBot` assignee type when assigning the
conversation back to a human in the store.

## How to test
- Open a pending conversation assigned to an Agent Bot and verify the
banner says it is handled by that bot name.
- Verify the reply editor stays in private-note mode and public reply
mode cannot be selected while the conversation is pending.
- Click Take over and verify the conversation moves to open and is
assigned to the current agent.
- Open a pending Captain/Dialogflow-style conversation without an Agent
Bot assignee payload and verify the banner falls back to `a bot`.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-30 16:31:14 -07:00
Shivam Mishra
6ed0f11e6b chore: add diagnostic logs to imap email fetch pipeline (#15187)
Adds lifecycle log lines across the IMAP email fetch pipeline so we can
trace where a fetch run spends time or silently stops. Some IMAP inboxes
showed increasingly sparse fetch runs in production without matching
errors; existing logs did not cover job start, lock acquisition,
connection setup, or early-exit paths.

## What changed

- `Inboxes::FetchImapEmailInboxesJob`: log when a fetch job is enqueued
per inbox (temporary instrumentation).
- `Inboxes::FetchImapEmailsJob`: log job start, skip reason on early
return, lock attempt/acquisition, fetched count, processing completion,
job completion, and unexpected errors.
- `Imap::BaseFetchEmailService`: log successful IMAP connection and the
start of each header batch fetch.

All lines share the `[IMAP::FETCH_EMAIL_SERVICE]` tag for searchability.
No behavior changes.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-30 16:27:59 +05:30
Tanmay Deep Sharma
0c606babea feat: time based automation (#15022)
## Description

Add automations that trigger based on how long a conversation has been
in a given state.

## Type of change

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

## How Has This Been Tested?

- UI flows 
- Specs (https://github.com/chatwoot/chatwoot/pull/15021) 

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-30 16:04:17 +05:30
Aakash Bakhle
38a317962b fix: Captain handles message bursts with a single reply (#15133)
Captain now handles a burst of customer messages with one reply. If more
messages arrive before Captain replies, the latest job uses the full
conversation history. The change applies only to Captain V2 and works
across every channel that Captain supports.

Blocked on: https://github.com/chatwoot/chatwoot/pull/15212

## Closes

Closes https://github.com/chatwoot/chatwoot/issues/14545

## How to reproduce

1. Start a pending conversation with Captain V2.
2. Send several messages while Captain is preparing a reply.
3. Captain can generate and send a separate reply for each message.

## What changed

* Each Captain V2 job records the incoming message that started it.
* A job stops before generation if a newer message already exists.
* Captain discards a generated reply if a newer message arrived during
generation.
* The latest job replies using the full conversation history.
* Langfuse records whether a generation was discarded and whether a
customer credit was used.
* Captain V1 keeps its existing behavior.

## Tradeoffs

Discarded generations still cost money. Message bursts can also increase
background job work and model provider load. A continuous stream of
incoming messages can delay the reply until one generation finishes
without a newer message.

A small timing window remains if a message arrives after the final check
and before Captain saves the reply. A handoff also cannot be undone if a
newer message arrives after Captain has already changed the conversation
status.

## How to test

1. Enable Captain V2 and start a pending Captain conversation.
2. Send several messages while Captain is preparing a reply.
3. Confirm that Captain sends one reply based on the full message
history.
4. Confirm that Langfuse marks discarded runs with `discarded=true` and
`credit_used=false`.
5. Disable Captain V2 and confirm that Captain V1 behavior is unchanged.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-30 15:58:54 +05:30
Muhsin Keloth
34d63454db fix(whatsapp): surface webhook registration errors (#15252) 2026-07-30 13:31:49 +04:00
Muhsin Keloth
9d769dfcdd fix(whatsapp): collect text header parameters (#15199)
WhatsApp templates with variables in both a text header and body
currently show inputs only for the body. Agents therefore cannot provide
the header value in the template composer, even though the API and
backend support the corresponding `processed_params.header` payload.

The composer now displays text-header variables separately, previews
their substituted values, and sends them alongside body parameters.
Templates using media headers remain unchanged.

Related: https://github.com/chatwoot/utils/pull/65

### Things to know

This PR consumes the released `@chatwoot/utils@0.0.57`, which adds
text-header parameter construction and completeness validation.

### How to reproduce

1. Open a WhatsApp template containing text header `Welcome {{1}}` and
body variables `{{1}}` and `{{2}}`.
2. Observe that the current composer displays only two body inputs and
omits the header input.

### How to test

1. Open the same template in the conversation composer.
2. Confirm one header input and two body inputs are displayed.
3. Fill the values and confirm both the header and body previews update.
4. Send the template and confirm `processed_params` contains `header.1`,
`body.1`, and `body.2`.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-29 20:34:33 +04:00
Sony Mathew
502c45f73b fix: freeze SLA misses after resolution (#15024)
## Description

Resolved conversations now preserve historical SLA misses without
allowing their displayed duration to keep growing. Applied SLAs record a
stable completion timestamp that is shared through REST and realtime
payloads, and the dashboard freezes FRT, NRT, and RT misses at that
point.

Legacy completed SLAs without a reliable timestamp remain visible as a
static missed state. Terminal SLAs remain frozen when a conversation is
reopened; a reopen before finalization continues the same SLA without
resetting its deadlines.

### Closes


[CW-7597](https://linear.app/chatwoot/issue/CW-7597/freeze-sla-miss-durations-after-conversation-resolution)

## Type of change

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

## How to reproduce

1. Apply an SLA with a resolution-time threshold to a conversation.
2. Let the threshold breach, then resolve the conversation.
3. Observe that the recorded miss duration continues increasing every
minute even though the conversation is resolved.

## What changed

- Added nullable `applied_slas.completed_at` and exposed it as
`sla_completed_at` in conversation, report, and websocket payloads.
- Captured completion before broadcasting resolution and preserved it
for terminal applied SLAs.
- Frozen recorded FRT, NRT, and RT durations in classic and
next-generation conversation labels, including a static fallback for
legacy rows.
- Added a dry-run-first, resumable Rails runner for account-scoped or
explicitly global historical repair without enqueuing jobs or touching
`updated_at`.

Account-scoped production rollout starts with:

```sh
ACCOUNT_ID=168154 bundle exec rails runner script/backfill_applied_sla_completed_at.rb
ACCOUNT_ID=168154 APPLY=true bundle exec rails runner script/backfill_applied_sla_completed_at.rb
```

## How Has This Been Tested?

- Verified resolution stamping, nonterminal reopen clearing, and
terminal reopen preservation.
- Verified dry-run, apply, account/global scope, resume, skip,
idempotency, and timestamp-preserving backfill behavior.
- Verified all three miss types freeze and existing conversation-card
behavior remains intact.
- 71 focused RSpec examples and 37 focused Vitest examples pass.
- RuboCop, ESLint, and diff checks pass.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-29 18:21:26 +05:30
Sony Mathew
651db39765 fix: harden article author updates (#15229)
## Description

Article edits now ignore an `author_id` that does not belong to the
current account, retain the existing author, and still apply other valid
article changes. Article creation continues to reject cross-account
authors with a generic validation error and without creating a record.

The authenticated article serializers still omit authors without a
current-account membership so existing forged or stale records cannot
expose agent profile fields. The guard now checks `current_account_user`
directly to make that intent explicit.

## Closes

Follow-up to [CW-7665](https://linear.app/chatwoot/issue/CW-7665) and
[#15191](https://github.com/chatwoot/chatwoot/pull/15191).

## Type of change

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

## How Has This Been Tested?

- Creating an article with a cross-account author returns `422` and does
not create an article.
- Updating an article with a cross-account author retains the previous
author while applying other valid attributes.
- Articles whose previous author is no longer an account member can
still be edited without exposing that author's agent profile.
- Existing OSS and Enterprise article request coverage passes locally.

## Checklist:

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

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-07-29 17:28:45 +05:30
Sivin Varghese
754b25cd59 feat: add Video option to the article editor slash menu (#15164) 2026-07-29 17:25:58 +05:30
Muhsin Keloth
59eac9a7c5 feat(whatsapp): add cloud template management token (#15218)
Chatwoot Cloud customers can now provide a dedicated WhatsApp business
management token when their Embedded Signup credential cannot access
message templates. Once validated, the token is stored securely and used
only for template synchronization.

Existing inboxes continue using their configured WhatsApp API key when
no business management token is present. Sending, receiving, webhooks,
phone-number health, and other WhatsApp operations remain unchanged.

### Things to know

- This option is available only on Chatwoot Cloud.
- Saving the token verifies that `whatsapp_business_management` is
granted through Meta's permissions endpoint; template synchronization
still verifies access to the configured WhatsApp Business Account.
- The token is encrypted using the existing external-credentials
encryption mechanism.
- Self-hosted installations continue using the existing API key flow.

### How to test

1. On Chatwoot Cloud, open a WhatsApp Cloud inbox and go to
**Configuration**.
2. Enter a token with `whatsapp_business_management` access and save it.
3. Confirm the token is accepted and the value is not exposed again in
the UI or API.
4. Select **Sync Templates** and confirm templates are fetched with the
saved business management token.
5. Remove the token and confirm template synchronization falls back to
the inbox API key.
6. Confirm the business management token controls are not shown on a
self-hosted installation.

### What changed

- Added an encrypted `business_management_token` credential to WhatsApp
channels.
- Added Cloud-only endpoints and UI controls to validate the required
permission, save, and remove the token.
- Added template-sync credential selection with API-key fallback.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-29 13:37:59 +04:00
Sivin Varghese
2e9423cbba fix: unread notification dot and unread preview sizing (#15216) 2026-07-29 14:16:26 +05:30
Vishnu Narayanan
d5f7a2be64 fix: don't show the self-hosted upgrade banner on chatwoot cloud (#15195)
## Description

The dashboard "update to vX.Y.Z is available" banner is a self-hosted
upgrade nudge, but nothing gates it to self-hosted installs. On a
managed cloud instance the banner can still appear:
`latest_chatwoot_version` is populated in Redis from the hub's
advertised latest stable release, and the frontend compares it
(`semver.lt`) against the per-request `appVersion` baked into the
dashboard HTML. When a browser session is holding HTML rendered before
the current deploy, `appVersion` lags the hub's advertised version and
the banner fires, even though the managed instance is already on the
newest build.

This gates the value at the source: `api/v1/accounts#show` no longer
sets `latest_chatwoot_version` when running on cloud, so
`hasAnUpdateAvailable` short-circuits (`semver.valid(null)` is false)
and the banner never renders there. Self-hosted behaviour is unchanged.

Fixes https://linear.app/chatwoot/issue/CW-7763
2026-07-29 13:32:52 +05:30
Muhsin Keloth
fe6f900db9 fix(inboxes): guide users through meta api incident (#15210)
An API-level issue on Meta’s side is preventing Chatwoot Cloud customers
from completing some Meta inbox setup flows and can affect actions in
existing Instagram conversations. This draft temporarily gates the
affected Facebook, Instagram, and WhatsApp flows behind one incident
flag and gives users clear guidance with a link to the current status
incident.

Self-hosted installations remain unchanged. While WhatsApp Embedded
Signup is unavailable, selecting WhatsApp Cloud takes eligible users
directly to manual setup and explains the current limitations.

Related: https://status.chatwoot.com/incident/976975

Fixes
https://linear.app/chatwoot/issue/CW-7771/guide-users-through-meta-api-incident

### Things to know

- `IS_META_INBOX_CREATION_DISABLED` is the single temporary rollout flag
and is currently enabled.
- The incident behavior applies only to Chatwoot Cloud.
- Facebook, Instagram, and WhatsApp Embedded Signup are removed from the
new onboarding suggestions while the incident is active.
- Existing Instagram inbox settings and conversations show incident
guidance with a status link.
- The WhatsApp Cloud provider routes to manual setup while the flag is
enabled.
- Manual setup works only for numbers already connected to WhatsApp
Cloud API; numbers using WhatsApp Business app coexistence are not
supported by this flow yet.
- The flag should be disabled or removed after the incident is resolved.

### How to test

1. Run Chatwoot with the Cloud installation configuration and open the
new inbox flow.
2. Confirm Facebook and Instagram authentication actions are disabled
and display the amber incident banner.
3. Open the WhatsApp provider selector and confirm WhatsApp Cloud is
described as manual setup with Cloud API credentials.
4. Select WhatsApp Cloud and confirm it opens the manual setup form with
the incident warning and links to the current status incident.
5. Open WhatsApp Embedded Signup directly and confirm the signup action
is disabled while the eligible manual setup option remains available.
6. Open the initial account onboarding flow and confirm Meta channels
are not offered while other configured channels remain available.
7. Open an existing Instagram inbox and conversation and confirm the
incident guidance links to the current status incident.
8. Run the same flows as a self-hosted installation and confirm they
remain available.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-29 09:56:03 +04:00
Sivin Varghese
0751175693 chore: register Slovenian (sl) locale in survey (#15217) 2026-07-28 22:29:32 +05:30
Matic Bončina
ce8cbf216e feat: register Slovenian (sl) locale in the live-chat widget (#15148) 2026-07-28 19:40:05 +05:30
Vishnu Narayanan
1b487f49d8 fix: reject cross-account author on help center articles (#15191)
## Description

The help center article endpoints permit `author_id` and render the
author through the agent serializer (`_agent.json.jbuilder`), which
exposes `email` plus name, role, and availability. `author_id` was never
scoped to the current account, so a forged or stale author disclosed the
profile of a user in another account. This is the article path of the
same serializer disclosure class as the conversation participants fix.

The fix has two parts:

- **Serializer guard (the disclosure fix).** The article partials now
render the author only when they are a member of the current account
(`article.author&.account`). This closes the leak on every read and for
every row, including articles that already carry a forged or stale
out-of-account author, and mirrors how the conversation assignee is
already handled.
- **Create-only validation.** `author_id` is checked against the
account's users on create, so a new article cannot be forged with an
out-of-account author. Update needs no guard: a non-member author is
simply never rendered, so editing an article whose author has left the
account continues to work.

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

Related: https://github.com/chatwoot/chatwoot/pull/15180
(https://linear.app/chatwoot/issue/CW-7746).
2026-07-28 18:37:07 +05:30
Muhsin Keloth
d04a717701 fix(whatsapp): resolve replies across scoped message ids (#15107)
WhatsApp coexistence replies can carry a BSUID-scoped `context.id` even
when Chatwoot stored the original message with a phone-scoped WAMID.
Although both identifiers refer to the same message, their complete
values differ, so incoming replies retained the external reference but
did not populate the internal `in_reply_to` relationship. Agents
consequently saw the reply text without the quoted-message preview.

This resolves the original message within the selected conversation and
stores the internal reply relationship. Exact WAMID matches remain the
primary path; scoped identifiers fall back to the unique decoded message
token.

Fixed
https://linear.app/chatwoot/issue/CW-7663/whatsapp-quoted-replies-are-not-linked-across-scoped-wamids
and https://github.com/chatwoot/chatwoot/issues/14953

## How to reproduce

1. Use a WhatsApp Cloud inbox with coexistence enabled.
2. Send a message whose source ID is stored using the phone-scoped
WAMID.
3. Reply to it from WhatsApp when the webhook carries a BSUID-scoped
`context.id` for the same message.
4. Before this change, the incoming message appears without its
quoted-message preview.
5. After this change, the reply references and displays the original
message.

## What changed

- Resolve incoming reply context IDs against messages in the selected
conversation.
- Keep exact source-ID matching as the first lookup path.
- Decode scoped WAMIDs and match only a unique 20- or 32-character
message token.
- Populate `content_attributes.in_reply_to` while preserving
`in_reply_to_external_id`.
- Leave malformed, unmatched, or ambiguous identifiers unlinked.

## How to test

1. Open a WhatsApp Cloud conversation and send a message to the contact.
2. Reply to that message from WhatsApp through a coexistence identity.
3. Confirm the incoming message displays the original message as a
quoted preview.
4. Confirm ordinary exact-ID replies continue to resolve.
5. Confirm an unknown or malformed context ID does not attach to another
message.

## Things to know

Meta documents `context.id` as the replied-to message identifier, but
does not document the internal WAMID encoding or the phone-versus-BSUID
scope transformation. The fallback is therefore limited to the selected
conversation and succeeds only when one stored message has the decoded
token.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-28 15:28:20 +04:00
Vishnu Narayanan
3a8c5117da fix(perf): lazy load super admin dashboard stats (#15147)
The super admin landing page runs all its stat queries synchronously
before rendering anything. On large installs the exact `COUNT(*)` on
conversations and the 30-day chart group-by scan the whole table and
exceed the request timeout, so the first page load fails and the console
is unreachable.
2026-07-28 15:54:51 +05:30
Shivam Mishra
64301495b4 fix: default Captain overview to last 7 days (#15206)
The Captain agents overview now defaults to the last 7 days instead of
this month, so the page opens on a more recent and actionable window.

## What changed

- Overview page, range selector, and welcome card default to `7`.
- Backend `Captain::AssistantStatsWindow::DEFAULT_RANGE` changed from
`30` to `7`, so requests without a `range` param (or with invalid
values) also resolve to the last 7 days.

## How to test

- Open Captain → Overview: the range selector should show "Last 7 days"
by default and metrics should reflect that window. Other ranges continue
to work as before.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-28 13:33:08 +05:30
enzogtrujillo
acae0a2acf feat(conversations): add opt-in merge for custom attributes (#15119)
Adds an opt-in merge flag to the conversation custom attributes endpoint
so integrations can update only the keys they send instead of replacing
the whole hash, matching how the contacts endpoint already behaves. Also
adds a destroy_custom_attributes endpoint to remove specific keys,
mirroring the contacts convention. Replace stays the default, so
existing integrations are unaffected.

How to test

POST /conversations/:id/custom_attributes com { "custom_attributes":
{"a":1} }, then { "custom_attributes": {"b":2}, "merge": true } results
in {a:1, b:2}; no merge, results in {b:2}.
POST /conversations/:id/destroy_custom_attributes with {
"custom_attributes": ["a"] } removes only a.

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-07-28 12:43:56 +05:30
Muhsin Keloth
3479b1026e fix(whatsapp): preserve phone health when business enrichment fails (#15200)
WhatsApp phone-number health could be discarded when the separate WABA
business-information request failed due to missing business-management
permissions. This left the health snapshot empty even though Meta
successfully returned the phone’s status, quality, capacity, and other
operational fields.

This change treats WABA information as optional enrichment after a
successful phone-number request. Available phone health is persisted and
returned, while the enrichment failure remains recorded in
`phone_number_health_error`. Failures from the primary phone-number
request continue to follow the existing error path.

Related: https://github.com/chatwoot/chatwoot/pull/15100

### How to reproduce

1. Configure a WhatsApp Cloud API inbox whose token can read its
phone-number node but cannot access `owner_business_info` on the WABA.
2. Open Settings → Inbox → Account Health or run the scheduled health
sync.
3. Observe that the phone-number request succeeds while the WABA request
returns a permission error.

### How to test

1. Refresh Account Health for the affected inbox.
2. Confirm the available phone status, quality rating, messaging limit,
verification state, throughput, and coexistence information are
populated.
3. Confirm unavailable business-account and portfolio fields remain
absent.
4. Confirm the WABA permission failure is retained in the channel’s
health error field.
5. Confirm a failure from the phone-number endpoint still records the
error without replacing the last successful health snapshot.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-28 09:56:56 +04:00
Marco Cabral
a3a961919e fix(whatsapp): send messages to business scoped user ids (BSUID) (#15150)
## Description

WhatsApp's migration to usernames / **Business-Scoped User IDs (BSUID)**
means a contact can become addressable only by a BSUID (format
`CC.<id>`, e.g. `BR.1393...`) when no phone number is exposed. Per
Meta's Cloud API, a BSUID recipient must be sent in the **`recipient`**
field (with `recipient_type: "individual"`), **not** in `to`.

Today `Whatsapp::Providers::WhatsappCloudService` always places the
recipient in `to`. When `to` carries a BSUID, the Graph API returns
**HTTP 200 with a message id** but **silently drops the message**: it
strips the country prefix and treats the remainder as a phone number
(`wa_id`), which never resolves, so nothing is delivered and **no error
is surfaced**. In the username-only era this means agents reply into the
void — Chatwoot marks the message as sent while the customer receives
nothing.

This PR adds a small helper, `recipient_params`, that routes the
outgoing identifier to the correct field:

- a **BSUID** → `{ recipient_type: "individual", recipient: <bsuid> }`
- a **phone number** → `{ to: <phone> }` (unchanged behaviour)

It is applied to all four Cloud send paths: text, attachment, template
and interactive. BSUID detection reuses the existing
`RegexHelper::WHATSAPP_BSUID_REGEX`. No Graph API version bump is
required (see testing below).

Related to #13837 (this covers the **outbound sending** part).

### References
- Meta — *Business-scoped user IDs*:
https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids/
— "set `recipient` to the user's BSUID or parent BSUID"; when both `to`
and `recipient` are present, `to` takes precedence.
- BSUIDs began appearing in webhooks in April 2026; sending **to** a
BSUID was enabled by Meta in July 2026.

## 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 to not work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Verified **live** against a real, Meta-registered WhatsApp Cloud number,
sending to a real BSUID captured from an inbound webhook, on **both
Graph API v13.0 (the current default in this service) and v22.0**:

| # | API version | recipient field | Meta response | delivered? |
|---|-------------|-----------------|---------------|:----------:|
| 1 | v22.0 | `recipient` = BSUID | `200` · `contacts[].user_id` echoed
|  |
| 2 | v22.0 | `to` = BSUID | `200` · `contacts[].wa_id` (prefix
stripped) |  |
| 3 | v13.0 | `recipient` = BSUID | `200` · `contacts[].user_id` echoed
|  |

- When the API echoes `user_id`, the BSUID is accepted and the message
**is delivered**; when it echoes `wa_id` (prefix stripped), it is
**not** — confirmed on the receiving handset.
- **v13.0 already accepts `recipient`**, so no Graph API version bump is
needed.
- Added unit specs asserting the request body uses `recipient` +
`recipient_type` for a BSUID and `to` for a phone number, across the
send paths.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented 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
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-27 13:35:22 +04:00
Petterson
b2716e15e1 fix: deregister WhatsApp Cloud number on inbox delete (#14940)
## Description

When a WhatsApp Cloud inbox is deleted,
`Whatsapp::WebhookTeardownService` clears the phone-level webhook
override and unsubscribes the app from the WABA (when it's the last
inbox), but it **never deregisters the phone number**.

Because the number stays registered to the app, Meta reports that the
number is **"already registered to a partner app"** when the user later
tries to re-add it under a different app/BSP — leaving the number
effectively stuck.

This adds a deregister step so the number is released on deletion:
- New `Whatsapp::FacebookApiClient#deregister_phone_number` → `POST
/{phone_number_id}/deregister`.
- `WebhookTeardownService` calls it during teardown (alongside the
existing override-clear and app-unsubscribe), guarded on
`phone_number_id` and wrapped so a failure is logged and never blocks
the channel delete.

Docs:
https://developers.facebook.com/docs/whatsapp/cloud-api/reference/registration
(deregister)

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

## How has this been tested?
Unit specs for both the new API client method and the teardown service
(Meta API stubbed with WebMock), mirroring the existing
`register_phone_number` / teardown coverage.

## Checklist
- [x] My code follows the style guidelines of this project
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes

---------

Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
2026-07-27 14:36:33 +05:30
Sony Mathew
1fa9127a72 fix: harden conversation participant api (#15180)
Prevents the conversation participants endpoints from returning profile
details for users who cannot be assigned to the conversation's inbox.
Invalid participant IDs are now rejected before any participant records
are changed, so mixed valid and cross-account payloads fail atomically
without exposing user data.
2026-07-27 13:17:57 +05:30
Sivin Varghese
19c96fcc07 fix: prevent page overflow from hidden tooltips (#15156) 2026-07-24 20:06:33 +05:30
Sony Mathew
7c1711170b feat: Add account suspension metadata in Super Admin (#15158)
## Description

Super Admins can now record a category and reason when suspending an
account, review the complete suspension history on the account details
page, and correct the latest suspension metadata without losing its
original timestamp. Suspension events are stored internally on the
account without changing customer-facing account API payloads.

## Closes

-
[CW-7653](https://linear.app/chatwoot/issue/CW-7653/ability-to-add-notes-while-suspending-an-acocunt)
- [Implementation
plan](https://linear.app/chatwoot/document/super-admin-account-suspension-metadata-implementation-plan-e7e4eb79d078)

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

## What changed

- Require a suspension category and a reason of up to 256 characters
when an active account is suspended.
- Store append-only suspension events in `accounts.internal_attributes`,
while preserving unrelated internal metadata.
- Allow corrections to the latest event for an already suspended account
without changing its timestamp.
- Show the full suspension history, newest first, on the Super Admin
account details page.
- Add visual dividers between top-level sections on the Super Admin
account edit page.
- Keep legacy suspended-account edits and new-account creation behavior
unchanged.

## How to test

1. Open an active account in Super Admin and choose **Suspended**.
2. Confirm the category and reason controls appear, reject incomplete or
invalid values, and enforce the 256-character reason limit.
3. Suspend the account with each supported category and confirm the
event appears on the details page.
4. Reactivate and suspend the account again; confirm prior history is
retained and a new event is added.
5. Edit a suspended account's latest category or reason; confirm its
original timestamp is preserved.
6. Confirm a legacy suspended account without history can still be
edited without supplying suspension metadata.

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-24 14:48:14 +05:30
Shivam Mishra
afa6421809 feat: allow suspended accounts to access billing (#15153)
Administrators of suspended accounts can now reach the billing page to
settle payment and restore their account, instead of being fully locked
out on the suspended screen. The suspended screen shows a subtle
**Manage billing** link below the contact support button (admins on
Cloud only). Agents remain restricted to the suspended screen.

## How to test

1. On Cloud, suspend an account from Super Admin.
2. Log in as an administrator of that account — you land on the
suspended screen with a "Manage billing" link below contact support.
3. Click it — the billing settings page opens; subscription, Stripe
portal, and top-ups all work.
4. Log in as an agent — no billing link, and navigating to
`/settings/billing` manually redirects back to the suspended screen.

## What changed

- Router guard allows `billing_settings_index` for administrators when
the account is not active.
- Suspended screen gets a billing link gated by admin role and Cloud
installation.

<img width="3024" height="1718" alt="CleanShot 2026-07-24 at 12 25
56@2x"
src="https://github.com/user-attachments/assets/1905d070-5e67-4283-9c8d-6e9936bba9a3"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-24 14:47:58 +05:30
Sony Mathew
65499df6a3 fix(email): allow larger email templates (#15146)
## Description

Branded email layouts and other email templates can now contain up to
262,144 characters instead of the generic 20,000-character text limit.
This supports customer layouts around 41 KB and 100 KB while retaining a
defined application-level ceiling. The account and inbox API schemas now
expose the same maximum.

## Closes


[CW-7682](https://linear.app/chatwoot/issue/CW-7682/allow-larger-branded-email-templates)

Related:
[CW-7514](https://linear.app/chatwoot/issue/CW-7514/branded-html-email-templates-per-inboxbrand)

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] This change requires a documentation update

## How Has This Been Tested?

1. As an administrator with `branded_email_templates` enabled, update an
account branded layout with a 100 KB Liquid layout containing `{{
content_for_layout }}` and confirm the request succeeds.
2. Update an Email inbox layout with more than 262,144 characters and
confirm the API returns `422`.
3. Confirm a layout at exactly 262,144 characters remains valid.

## 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
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-24 14:00:01 +05:30
Sony Mathew
6baf442c28 fix: enforce email limits for agent invitations (#15082)
# Pull Request Template

## Description

Prevents Chatwoot Cloud accounts from exceeding their daily non-channel
email allowance through agent invitations. New-user invitations
atomically reserve email capacity before mail is queued; when the budget
is exhausted, agent creation rolls back and returns HTTP 429.

This covers single and bulk agent creation. Self-hosted installations
remain unaffected, and adding an existing user does not consume capacity
when no invitation is sent.

Related to
[CW-7637](https://linear.app/chatwoot/issue/CW-7637/prevent-agent-invitation-email-abuse-after-july-20-incident).

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

Verified single and bulk creation at an exhausted budget, successful
invitation enqueueing below the limit, no capacity usage for existing
users, and no enforcement on self-hosted installations. A concurrent
Redis probe admitted exactly five of twenty simultaneous reservations
against a limit of five.

## 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-07-24 13:59:28 +05:30
Muhsin Keloth
d644c207f0 feat: monitor whatsapp phone number health (#15100)
Adds scheduled WhatsApp Cloud API phone-number health synchronization
and expands the Account Health page with phone, capacity,
business-account, webhook, coexistence, and recovery details.
Authorization failures now guide administrators to the appropriate
Configuration flow without exposing technical Meta error codes.

Fixes
[CW-7621](https://linear.app/chatwoot/issue/CW-7621/store-whatsapp-phone-number-health-status)

**Preview**

<img width="2594" height="1676" alt="CleanShot 2026-07-22 at 10 55
01@2x"
src="https://github.com/user-attachments/assets/de606cb4-5682-4178-87e9-c18752d299b5"
/>

<img width="2576" height="1506" alt="CleanShot 2026-07-22 at 10 55
08@2x"
src="https://github.com/user-attachments/assets/4eb1810f-be12-4fbc-bcb0-9e2906785c48"
/>


<img width="1748" height="1150" alt="CleanShot 2026-07-22 at 11 10
05@2x"
src="https://github.com/user-attachments/assets/64f5be5a-c127-4372-889f-d392947c13b8"
/>



### How to test

1. Open **Settings → Inboxes → a WhatsApp Cloud API inbox → Account
Health**.
2. Confirm the page shows separate Phone number, Health and capacity,
Business account, and Webhook configuration sections.
3. Confirm the configured webhook URL can be copied and the expected URL
appears only when it differs.
4. For a coexistence number, confirm **Coexistence · Active** appears;
confirm it is hidden for standard Cloud API numbers.
5. With an invalid Embedded Signup token, confirm the page asks to
refresh the WhatsApp connection and **Go to Configuration** opens the
Configuration tab.
6. With an invalid manually configured token, confirm the page asks the
administrator to verify or replace the access token.
7. Confirm authorization states do not display Meta error codes or the
manual-migration recommendation.

### What changed

- Persists the latest successful phone health snapshot, check time, and
most recent error while retaining the last successful data after a
failed refresh.
- Refreshes stale active Cloud API channels every six hours through
low-priority jobs.
- Fetches phone, WABA, business portfolio, webhook, and coexistence
details.
- Uses Meta's current `whatsapp_business_manager_messaging_limit` field
while preserving the existing UI response key.
- Classifies authorization failures for setup-specific recovery guidance
and logs new risky quality/status transitions.
- Adds focused service, scheduler, job, trigger, and API coverage.

Internal alerts, throttling, automatic inbox disablement, and customer
notifications remain outside this PR and are tracked separately in
CW-7622.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-24 12:00:37 +04:00
Aakash Bakhle
7910eabefc feat(captain): add FAQ suggestion review interface (4/4) (#15017)
Captain now groups recurring questions from resolved conversations into
FAQ suggestions and orders them by the number of source conversations.
Agents can view suggestions and open source conversations they can
access. Administrators can edit, approve, or dismiss suggestions.

The old pending FAQ flow is removed. The Captain overview and FAQ page
now use open suggestion counts and link to the same review page.
Approved FAQs remain unchanged.

## Depends on

#14979

## Closes

https://linear.app/chatwoot/issue/CW-7496/fe-and-ux

## How to test

1. Open Captain and choose an assistant with open FAQ suggestions.
2. Open FAQ suggestions from the overview or the FAQ banner. Confirm
that suggestions are ordered by conversation count.
3. Switch assistants without leaving the page. Confirm that the previous
results clear and the new assistant results load.
4. Search for suggestions and move between pages. Change the search or
page again before the first request finishes, and confirm that the
latest request controls the results and loading state.
5. Open a suggestion and review its source conversations.
6. Make the source conversation request fail. Confirm that the dialog
keeps the error visible and that Retry loads the sources.
7. Sign in as an agent. Confirm that you can read suggestions and source
conversations you can access, but cannot edit, approve, or dismiss
suggestions.
8. Sign in as an administrator. Edit and save a suggestion, approve one
suggestion, and dismiss another.
9. Confirm that the approved suggestion appears in the assistant FAQ
list.
10. Open the old pending FAQ URL and confirm that it redirects to FAQ
suggestions.

## What changed

1. Added the FAQ suggestion list, cards, search, pagination, and empty
state.
2. Added a review dialog with source conversation links, a clear error
message, and a Retry button.
3. Added edit, approve, and dismiss actions for administrators.
4. Removed the old pending FAQ status, count, page, and bulk approval
action.
5. Updated the Captain overview and FAQ banner to use open suggestion
counts and link to FAQ suggestions.
6. Made each FAQ page load data for the selected assistant and ignore
results from older requests.
7. Kept the old pending FAQ URL as a redirect so saved links continue to
work.

---------

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-07-24 12:53:09 +05:30
Sony Mathew
7786e38df9 feat: retry stalled Intercom imports after 15 minutes (#15050)
## Description

Adds a guarded retry action for Intercom imports that have not recorded
progress for 15 minutes. The API reports stalled state, rotates the
import run identifier under an account lock, preserves existing progress
and logs, and queues a fresh worker only when no other Intercom import
is active for the account. The import details page shows Retry
immediately before Abandon only while the server reports the import as
stalled.

This is Phase 1, Task 1 of the [Intercom import optimization plan
(CW-7615)](https://linear.app/chatwoot/issue/CW-7615/optimize-intercom-import-reliability-and-bulk-message-ingestion).

This replaces #15049 with the updated 15-minute threshold and is based
directly on the latest `develop`.

## Closes

-
[CW-7519](https://linear.app/chatwoot/issue/CW-7519/explore-intercom-import)

## Type of change

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

## How to test

1. Open a processing Intercom import updated within the last 15 minutes
and confirm Retry is hidden.
2. Set its updated timestamp to more than 15 minutes ago and reload the
details page.
3. Confirm Retry appears immediately before Abandon.
4. Retry the import and confirm it returns to Pending while existing
progress, logs, and the original start time remain intact.
5. Confirm a second retry is rejected and another active Intercom import
prevents queueing.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have added tests that prove the change is effective
- [x] New and existing focused tests pass locally with my changes
2026-07-24 12:30:36 +05:30
Sojan Jose
56e72eff8d feat: assign connected agent bot owner (#14875)
Connected Agent Bot-handled conversation creation now records the bot as
the explicit owner instead of relying on pending status alone.

Closes:
https://linear.app/chatwoot/issue/CW-7449/set-connected-agent-bot-as-owner-in-bot-handled-flows

## Why
When an inbox has a connected Agent Bot, bot-handled conversations
already move to pending for queue placement. They should also carry
explicit Agent Bot ownership so agents can see that the bot is handling
the conversation and later takeover/hand-back behavior has a real owner
to work with.

## What changed
- Sets active connected Agent Bot inbox conversations to pending.
- Sets `assignee_agent_bot` to the connected Agent Bot when no explicit
human assignee is present.
- Clears the human `assignee` when assigning the connected Agent Bot
owner.
- Preserves explicit human assignees on conversation creation.
- Sets bot-initiated campaign conversations in active Agent Bot inboxes
to pending and owned by the connected Agent Bot.
- Keeps human-sender campaign conversations open and without Agent Bot
ownership.
- Leaves Dialogflow pending behavior without Agent Bot ownership.
- Clears `assignee_agent_bot` when the bot hands the conversation off.

## Validation
- Create a conversation in an inbox with an active connected Agent Bot
and verify it is pending and owned by the Agent Bot.
- Create a conversation in that inbox with an explicit human assignee
and verify the human assignee is preserved.
- Create a bot-initiated campaign conversation in the same inbox and
verify it is pending and owned by the Agent Bot.
- Create a human-sender campaign conversation and verify it remains open
without Agent Bot ownership.
- Create a Dialogflow-handled pending conversation and verify no Agent
Bot owner is set.
- Trigger bot handoff and verify the Agent Bot owner is cleared.
2026-07-23 19:22:35 -07:00