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>
## 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>
## 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>
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>
## 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
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>
## 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).
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>
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.
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)
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>
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>
## 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>
## 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>
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.
## 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>
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>
## 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
# 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
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>
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>
## 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
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.
Adds E2E UI tests for two core Phase 2 flows, building on the Playwright
setup from #13578.
**Agent onboarding** — validates the Agents settings page, Add Agent
modal elements, form validation (name + email required, submit disabled
until valid), and cancel behaviour.
**Inbox creation** — walks through the full API channel inbox creation
journey: channel selection → form fill → agent assignment → finish
screen.
## What changed
New UI component objects (`tests/playwright/components/ui/`):
- `agent-page.component.ts`
- `add-agent-modal.component.ts`
- `add-agents-form.component.ts`
- `settings-inbox-page.component.ts`
- `channel-selector.component.ts`
- `api-channel-form.component.ts`
- `finish-setup.component.ts`
New test specs (`tests/playwright/tests/e2e/ui/`):
- `agent-onboarding-flow-ui-validation.spec.ts`
- `inbox-creation-flow.spec.ts`
Updated `components/ui/index.ts` barrel export to include all new
components.
## How to test
```bash
cd tests/playwright
npx playwright test tests/e2e/ui/
```
All 5 tests pass locally (3 login + 2 new flows).
Closes part of the Phase 2 scope from the [Playwright E2E discussion
#13500](https://github.com/orgs/chatwoot/discussions/13500).
---------
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
This improves the Captain overview by loading reporting metrics and FAQ
stats from separate endpoints. Range changes now refresh only the
metrics, while reopen-rate calculation reuses the resolved conversation
count to avoid redundant database queries.
## What changed
- Split Captain overview metrics and FAQ stats into separate APIs.
- Fetch FAQ stats independently from range-based metrics.
- Reuse resolved conversation totals when calculating reopen rate.
- Skip the reopen query when there are no resolved conversations.
# Pull Request Template
## Description
Locks the agent quota check to the account row while creating account
users. This fixes a race where concurrent agent-create requests could
all observe the same remaining seat before any `account_users` row was
inserted.
The API continues to return the existing `402 Account limit exceeded.
Please purchase more licenses` response when the limit is reached. Bulk
create now preflights the requested email count while holding the
account lock, then creates each agent through the same locked builder
path. The Enterprise custom-role hook now no-ops when create did not
produce an agent.
Fixes:
[CW-7039](https://linear.app/chatwoot/issue/CW-7039/race-condition-in-agent-creation-bypasses-plan-agent-seat-limit)
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- `POSTGRES_DATABASE=chatwoot_test_c20f_agent_quota REDIS_DB=9 bundle
exec rspec spec/builders/agent_builder_spec.rb
spec/enterprise/builders/agent_builder_spec.rb
spec/controllers/api/v1/accounts/agents_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb
spec/enterprise/controllers/enterprise/api/v1/accounts/agents_controller_spec.rb`
- `bundle exec rubocop app/builders/agent_builder.rb
app/controllers/api/v1/accounts/agents_controller.rb
enterprise/app/controllers/enterprise/api/v1/accounts/agents_controller.rb
spec/builders/agent_builder_spec.rb
spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb`
- `git diff --check`
- One-off threaded Rails validation with 8 concurrent `AgentBuilder`
calls against an account with one remaining seat: `created: 1`,
`limited: 7`, final `count=2`, `limit=2`.
## 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
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Non-admin agents could delete an account's Linear, Notion, or Shopify
integration through the dedicated integration endpoints, which — unlike
the generic hooks endpoint — never checked the caller's role. This
restores the intended admin-only boundary for removing an integration.
## Closes
- https://linear.app/chatwoot/issue/CW-7383
- https://linear.app/chatwoot/issue/CW-7384
- https://linear.app/chatwoot/issue/CW-7189
## How to reproduce
As a non-admin **agent**, `DELETE
/api/v1/accounts/:id/integrations/{linear,notion,shopify}` returned
`200` and removed the account-wide integration. After this change it
returns `401` and the integration is preserved; administrators can still
remove it.
## What changed
- Route integration-hook deletion through `HookPolicy` (admin-only) via
a shared `Integrations::BaseController`, matching the generic hooks
controller.
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
## Description
The widget email-transcript button (`ChatFooter.vue`) had no client-side
guard. Its visibility depends only on whether the contact has an email,
and the click handler fired a request on every click with no in-flight
lock, no disabled state, and no post-send handling. A user could
therefore trigger a large number of duplicate transcript emails from a
single conversation just by clicking repeatedly.
This adds a re-entry guard in the handler, disables the button while a
send is in flight, and applies a short cooldown (15s) after a successful
send. Normal use is unaffected: the button sends once, shows the success
toast, then briefly disables and automatically re-enables so a genuine
later re-request still works. On failure the button stays enabled so the
user can retry immediately. The cooldown timer is cleared on unmount.
Using a timed cooldown (rather than a permanent post-send lock) also
avoids the button getting stuck disabled if a resolved conversation is
reopened and later re-resolved.
This is the client-side complement to the server-side rate limit added
in #15085.
Fixes https://linear.app/chatwoot/issue/CW-7640
# Pull Request Template
## Description
This PR fixes a crash where opening a conversation threw `TypeError:
Cannot read properties of null (reading 'localeCompare')` and prevented
the agent assignment dropdown from rendering.
Since #14866, agent bots are included in the assignable agents list.
`AgentBot#name` is not presence-validated, so system bots (account-less,
global) can have a `null` name. Those nameless bots flowed into
name-based operations that assumed a string, causing crashes and
warnings across multiple surfaces:
* **Assignment dropdown sort:** `getAgentsByAvailability` called
`a.name.localeCompare(b.name)`, causing a `localeCompare` `TypeError`.
* **Dropdown search:** `MultiselectDropdownItems` called
`option.name.toLowerCase()`, causing a `toLowerCase` `TypeError`.
* **Agent Bots settings:** `Avatar` received `name=null` for a `String`
prop, triggering a Vue prop validation warning.
### What changed
* Keep nameless agent bots in the assignment dropdown and render a `-`
fallback label in `useAgentsList`. These are still valid,
assignable-by-ID records: the assignable agents API includes accessible
bots, and `Conversations::AssignmentService` assigns them by ID.
Preserving them avoids hiding valid assignment targets. Bots are still
included only when `includeAgentBots` is enabled.
* Make the sort in `getAgentsByAvailability` null-safe by coercing
missing names to an empty string (defense in depth).
* Make the search filter in `MultiselectDropdownItems` null-safe
(defense in depth).
* Pass a null-safe `name` prop to `Avatar` in the Agent Bots settings
list to eliminate the Vue prop validation warning.
Fixes
https://linear.app/chatwoot/issue/CW-7670/agent-assignment-dropdown-crashes-with-cannot-read-properties-of-null
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
1. Have a system agent bot (`name: null`) that is assignable to an
inbox.
2. Open any conversation in that inbox.
* The agent assignment dropdown renders without console errors.
* The nameless bot is listed with a `-` label and can be assigned.
3. Go to **Settings → Agent Bots**.
* The page renders without the `Avatar` prop validation warning.
## 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
WhatsApp automations now fail locally when they attempt to send a
free-form message after the 24-hour customer service window has closed.
This avoids sending an invalid template request to Meta and gives users
a clear, actionable error instead of “Template not found or invalid
template name.”
Template messages continue to be sent whenever template parameters are
present. Free-form messages continue to be sent normally while the
conversation is replyable.
Fixes
https://linear.app/chatwoot/issue/PLA-183/prevent-whatsapp-automations-outside-the-24-hour-window-from-producing
### How to reproduce
1. Create a WhatsApp automation that sends a message without template
parameters.
2. Trigger it on a conversation whose 24-hour customer service window is
closed.
3. Observe that the message previously reached the template send path
and failed with a misleading provider error.
### How to test
1. Trigger an automation with template parameters and confirm it sends
as a template message.
2. Trigger an automation without template parameters inside the 24-hour
window and confirm it sends as a free-form message.
3. Trigger an automation without template parameters outside the 24-hour
window and confirm it fails locally with a clear error and makes no
request to Meta.
### Things to know
This changes only the invalid closed-window, no-template path. Existing
template and in-window message behavior remains unchanged.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Fixes
https://linear.app/chatwoot/issue/PLA-99/whatsapp-messages-dropped-for-brazilargentina-numbers-due-to-phone
Fixes https://github.com/chatwoot/chatwoot/issues/14492
Meta's WhatsApp Cloud API includes `display_phone_number` in webhook
payloads, but its format can differ from the number stored in Chatwoot's
channel record.
In Brazil, Meta omits the mobile 9 prefix. For example, it sends
55419XXXXXXX (12 digits) instead of 554199XXXXXXX (13 digits). In
Argentina, Meta adds an extra 9 after the country code. For example, it
sends 549XXXXXXXXXX instead of 54XXXXXXXXXX.
The whatsapp event job uses `display_phone_number` for an exact-match
channel lookup. When the formats do not match, the lookup returns nil
and the incoming message is silently dropped, logging:
`Inactive WhatsApp channel: unknown - <phone_number>.`
The fix extends `get_channel_from_wb_payload` to fall back to normalized
phone number matching using the existing PhoneNumberNormalizationService
normalizers (Brazil, Argentina), which were previously only used for
contact-level lookups.
---------
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
## Description
Agents can now place a WhatsApp call to a contact straight from the
contacts screen, even if that contact has never messaged in. Previously
the call only worked once a conversation already existed, so a freshly
added contact would fail with "Unable to start the call. Please try
again." — the only workaround was to get the contact to message the
channel first.
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Manually via UI
## 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: Muhsin Keloth <muhsinkeramam@gmail.com>
WhatsApp embedded signup now uses
`whatsapp_embedded_signup_inbox_creation` as the single Chatwoot Cloud
rollout gate for inbox creation, proactive reconfiguration, and
disconnected inbox reauthorization. The authorization endpoint enforces
the same gate, so the UI and backend remain consistent.
Self-hosted installations keep their existing behavior.
## Things to know
- This reuses the existing feature flag; there is no migration or schema
change.
- The feature is shown as “WhatsApp Embedded Signup Flow” in feature
management.
- `whatsapp_reconfigure` remains visible and honored for self-hosted
proactive reconfiguration to preserve existing accounts. It can be
deprecated after the self-hosted dependency is removed or migrated.
## How to test
1. On Chatwoot Cloud, enable `whatsapp_embedded_signup_inbox_creation`
for an account.
2. Confirm that new WhatsApp inbox creation, proactive reconfiguration,
and disconnected inbox reauthorization are available.
3. Disable the flag and confirm those entry points are hidden and
authorization requests are rejected.
4. On self-hosted, confirm proactive reconfiguration remains controlled
by the existing `whatsapp_reconfigure` account setting.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
WhatsApp contacts using coexistence are identified by more than one
source ID (a phone `wa_id` and a `BR.`/BSUID identity), so a single
contact ends up owning multiple `contact_inbox` records. The "reopen the
same conversation" feature scoped conversation reuse to a single
`contact_inbox`, so messages arriving under a different identity of the
same contact started a brand-new conversation — even with reopen enabled
— producing duplicate conversations.
This scopes reuse to the contact across all of its `contact_inbox`
records in the inbox instead of a single `contact_inbox`.
## Closes
- [CW-7651
](https://linear.app/chatwoot/issue/CW-7651/duplicate-conversations)
## How to reproduce
1. On a WhatsApp Cloud inbox with "reopen the same conversation" (lock
to single conversation) enabled.
2. Have a coexistence contact whose webhooks alternate between carrying
the phone `wa_id` and only the BSUID identity.
3. Before: each identity opens its own conversation → duplicates. After:
incoming messages reopen the contact's existing conversation regardless
of which identity the webhook carried.
## What changed
- `Whatsapp::IncomingMessageBaseService#set_conversation` now looks up
reusable conversations via `@contact.conversations.where(inbox_id:
@inbox.id)` instead of `@contact_inbox.conversations`.
- Updated existing specs to wire the conversation's `contact` to the
contact_inbox's contact, mirroring production data.
# Pull Request Template
## Description
Moves the WhatsApp & Twilio content-template logic to the shared
[`@chatwoot/utils`](https://github.com/chatwoot/utils)
([PR](https://github.com/chatwoot/utils/pull/62)) package so web and
mobile share one implementation. The neutral core takes the raw template
and returns `processed_params`, the same shape the web parsers already
use, so it's a drop-in with no behavior change.
- `templateHelper.js` / `URLHelper.js` → source `MEDIA_FORMATS`,
`findComponentByType`, `processVariable`, `buildTemplateParameters`,
`extractFilenameFromUrl` from the package
- `inboxes.js` → filters with shared `isSendableTemplate`
- `WhatsAppTemplateParser.vue` / `ContentTemplateParser.vue` →
`isFormInvalid` and Twilio media helpers now use the shared
`isWhatsAppComplete` / `isTwilioComplete` / `applyTwilioMediaFilename`
> Depends on the `@chatwoot/utils` release adding the shared template
API, bump `package.json` from `^0.0.55` to the published version before
merge.
Fixes
[CW-7540](https://linear.app/chatwoot/issue/CW-7540/web-templates-integration-with-utils)
## Type of change
- [x] Breaking change (Refactor)
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Token-authenticated requests to Agent Bots, Labels, and affected Captain
endpoints return normal responses again. The regression was caused by
duplicate `current_account` callbacks in subclasses moving account
resolution behind the API entitlement check, leaving `Current.account`
unset.
## Closes
- https://linear.app/chatwoot/issue/CW-7641/5xx-errors-in-agent-bot-apis
## How to reproduce
1. Send `GET /api/v1/accounts/:account_id/agent_bots` with a valid
administrator API access token.
2. Observe a `500` from `validate_token_api_access` because
`Current.account` is `nil`.
3. With this change, account resolution runs in the base-controller
order and the request succeeds.
## What changed
- Removed redundant `current_account` callbacks from account-scoped
controllers that already inherit the callback from
`Api::V1::Accounts::BaseController`.
- Kept the standalone direct-upload controller callback unchanged.
- Added regression coverage for administrator API-token access to Agent
Bots.