Commit Graph

6539 Commits

Author SHA1 Message Date
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
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
Tanmay Deep Sharma
94d7ccf5e9 fix(security): gate custom role APIs on the custom_roles feature (#15208)
Custom Roles is a premium feature, but the account's `custom_roles` flag
was never checked by the API. An account on a plan without Custom Roles
— or one whose plan was downgraded and had the flag revoked — could
still list, create, edit and delete custom roles, and could still attach
a `custom_role_id` to an agent through the agents API. Both paths now
require the feature.

## How to reproduce

1. Disable the `custom_roles` feature for an account (Super Admin →
Account → Features, or a plan downgrade).
2. `GET /api/v1/accounts/{id}/custom_roles` — returns 200 with the
account's roles instead of denying.
3. `PATCH /api/v1/accounts/{id}/agents/{agent_id}` with `custom_role_id`
— the role is assigned.

## What changed

- `Api::V1::Accounts::CustomRolesController` gains a
`ensure_custom_roles_feature_enabled` guard, matching the shape already
used by the SLA policies controller.
-
`Enterprise::Api::V1::Accounts::AgentsController#associate_agent_with_custom_role`
ignores `custom_role_id` when the feature is off, rather than writing
it.

Existing custom role assignments are deliberately left untouched — they
are evaluated at request time by `Enterprise::AccountUser#permissions`
and the conversation policy, where custom roles mostly *narrow* an
agent's scope. Dropping them on revocation would widen conversation
visibility for restricted agents, which is a separate product decision.

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-08-03 14:39:56 +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
Vishnu Narayanan
f5eb7a954d fix: restrict libvips to trusted image loaders (#15254)
## Description

Adds `VIPS_BLOCK_UNTRUSTED=1` to `.env.example`. This tells libvips to
only use its trusted, well-tested loaders when Active Storage generates
image variants, hardening image processing against untrusted uploads.
The setting requires libvips >= 8.13 and is silently ignored on older
versions.

Related to https://linear.app/chatwoot/issue/INF-92

## Type of change

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

## How Has This Been Tested?

Verified locally that thumbnail/variant generation for the common raster
formats (JPEG, PNG, GIF, WebP, TIFF, HEIC) is unaffected with the flag
enabled.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
2026-07-31 12:20:35 +05:30
Khush Raghav Nanda
d82c71e78a fix(conversation): remove deprecated style block (#15179) 2026-07-31 12:10:41 +05:30
Aakash Bakhle
b05f22da8d fix(captain): default paid accounts to V2 (#15262)
Paid Chatwoot Cloud accounts now receive Captain V2 during plan
reconciliation unless they are explicitly held on Captain V1. Older
accounts could otherwise start on V1 when they became paid after the V2
rollout because only newly created accounts carried the rollout
eligibility value.

## Closes

No linked issue.

## How to reproduce

1. Start with a cloud account created before the Captain V2 rollout.
2. Upgrade the account from the default plan to a paid plan.
3. Reconcile the Stripe subscription.
4. Confirm that Captain is enabled but Captain V2 remains disabled.

## What changed

1. Treat a missing rollout eligibility value as eligible for Captain V2
on paid plans.
2. Keep Captain V2 disabled when the rollout eligibility value is
explicitly set to false.
3. Keep the default plan behavior unchanged.
4. Update the billing reconciliation specs to cover existing paid
accounts, new accounts, and explicit V1 exceptions.
2026-07-31 11:51:00 +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
Vishnu Narayanan
0134e7f451 chore(ci): update bundler-audit ignore list (#15256)
`bundle-audit` in CI flags an Active Storage advisory with no patched
release on the Rails 7.1 line, failing lint on every open PR. Adds it to
the existing `.bundler-audit.yml` ignore list. Mitigated locally; remove
once on Rails 7.2.3.1+.

Related to https://linear.app/chatwoot/issue/INF-92

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2026-07-30 14:23:43 +05:30
Aakash Bakhle
d46d388fba feat: accept future Captain response payloads (#15212)
This is phase one of a rolling deployment for Captain burst handling.

The job now accepts an optional third argument named
`responding_to_message_id`. The job does not use the argument yet, and
no enqueue call changes in this PR. The change lets old workers accept
the future three argument payload while versions overlap during
deployment.

There is no behavior change.

This is a pre-requisite for
https://github.com/chatwoot/chatwoot/pull/15133 because if we introduce
another argument, and post deployment, older jobs will fail with
argument error and result in no captain replies

## Validation

* `bundle exec rspec
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
* `bundle exec rubocop
enterprise/app/jobs/captain/conversation/response_builder_job.rb`
2026-07-30 13:29:52 +05:30
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
Aakash Bakhle
4ac2432b77 fix(captain): respect channel message limits (#14982)
Captain now keeps v2 replies within each conversation channel's delivery
limit, preventing generated responses from being rejected by providers
such as Instagram, Facebook, and WhatsApp.

## Closes

-
https://linear.app/chatwoot/issue/AI-188/captain-should-respect-whatsapp-character-limits

## How to reproduce

1. Enable Captain v2 on an Instagram inbox.
2. Ask a question that produces a response longer than 1,000 characters.
3. Observe that Meta rejects the outgoing message with error 100 because
it exceeds Instagram's character limit.

## What changed

- Resolve the outbound character limit from the conversation channel,
including provider-specific Twilio limits.
- Add the resolved limit to the assistant and scenario prompts.
- Apply the same limit to the v2 structured response schema so the model
output conforms before delivery.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-29 17:56:40 +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
Sony Mathew
bc9839ed38 fix: stabilize conversation FAQ lock spec (#15236)
## Description

Backend CI no longer flakes when the conversation FAQ grouping-lock spec
runs after Rails has reloaded application constants.

The job already raises the intended lock-acquisition error and prevents
concurrent suggestion generation. This updates the assertion to compare
the error class name, preserving that behavior without depending on a
reload-sensitive Ruby `Class` object.

The failure was reproduced in [CircleCI backend job
171299](https://app.circleci.com/pipelines/github/chatwoot/chatwoot/116560/workflows/efcee2f1-5af8-4121-82c3-920002dc420b/jobs/171299).

## Type of change

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

## How Has This Been Tested?

The isolated job spec passes, the exact 18-way CircleCI shard selection
passes all 442 examples, and the changed spec passes Ruby lint.

## 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] New and existing unit tests pass locally with my changes
2026-07-29 17:06:31 +05:30
Sony Mathew
8fcd32f442 chore(search): support Elastic Cloud API keys (#15231)
# Pull Request Template

## Description

Adds API-key authorization support for Searchkick/OpenSearch so Elastic
Cloud deployments can configure advanced search with an Elastic API key
instead of embedding basic auth in the URL.

The initializer now accepts `OPENSEARCH_API_KEY` or
`ELASTICSEARCH_API_KEY` and forwards it as an `Authorization: ApiKey
...` header. `.env.example` also documents the
OpenSearch/Elasticsearch-compatible search variables.

Refs
https://linear.app/chatwoot/issue/CW-7511/populate-test-data-set-and-run-experiments

## Type of change

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

## How Has This Been Tested?

- `bundle exec ruby -c config/initializers/searchkick.rb`
- `bundle exec ruby -c spec/config/searchkick_spec.rb`
- `bundle exec rspec spec/config/searchkick_spec.rb`
- `bundle exec rubocop config/initializers/searchkick.rb
spec/config/searchkick_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-29 16:42:44 +05:30
Vishnu Narayanan
039a311327 fix: consolidate widget rack-attack throttles with per-endpoint kill switches + saner defaults (#14376)
## Description

Consolidates all widget API throttles under the single
`ENABLE_RACK_ATTACK_WIDGET_API` flag, each with its own independent kill
switch and configurable limit, with defaults tuned from real prod
traffic.

Fixes https://linear.app/chatwoot/issue/INF-83
Related to https://linear.app/chatwoot/issue/INF-77

Parent flag stays default-true; installs that disabled it keep today's
behavior (no widget throttling). Each endpoint adds
`ENABLE_RACK_ATTACK_WIDGET_<X>` + `RATE_LIMIT_WIDGET_<X>`.

- **conversations create**: keyed on (IP, website_token), 30/min (was
per-IP 6/12h)
- **messages create**: new throttle, (IP, website_token), 60/min (was
unthrottled)
- **contact update**: fixes a dormant bug. `resource :contact` gives the
singular URL `/api/v1/widget/contact`, but the throttle checked plural
`/api/v1/widget/contacts` so it never fired. Now active (60/1h per IP).
- **widget load**: 5 to 200/1h, tuned from prod (real per-IP loads top
out ~50/hr; the higher tail is crawlers/scrapers)
- **transcript**: 5/1h retained

Token precedence: the (IP, website_token) throttles read `website_token`
via ActionDispatch (query wins), matching the controller, so a
body-supplied token cannot fork the throttle bucket.

## Type of change

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

## How Has This Been Tested?

`ruby -c` and rubocop clean. No spec added, matching this file's
existing no-spec convention for throttles.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-07-29 16:32:06 +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
1e33bfd553 fix: add if_not_exists to conversations.created_at index migration (#15214)
## Description

Follow-up to #15147. The migration missed `if_not_exists`, so it fails
with `index already exists` on a re-run or when the index is pre-built.
Makes it idempotent, matching recent concurrent-index migrations.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2026-07-28 16:21:25 +05:30
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
Sivin Varghese
b89bd613f8 chore: support opening links in the editor with Cmd/Ctrl+Click (#15130) 2026-07-28 14:46:21 +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
Sony Mathew
ed5a099425 Merge branch 'release/4.16.2' into develop 2026-07-27 15:44:23 +05:30
Sony Mathew
9c202793db Bump version to 4.16.2 2026-07-27 15:42:11 +05:30
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