Compare commits

...

586 Commits

Author SHA1 Message Date
Sivin Varghese
bc60ffc312 chore: remove old Captain label suggestions (#16056)
# Pull Request Template

## Description


This PR removes the old Captain label suggestion card above the reply
box. Label suggestions now come from the Captain suggestions in the
conversation sidebar.

This also removes the Label Suggestion toggle in Captain settings and
the "Show label suggestions" option on the OpenAI integration. A
migration clears old saved values so existing accounts keep saving.

## Type of change

- [x] Breaking change (fix or feature that would cause existing
functionality not to work as expected)



## 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: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-10-06 16:58:12 -07:00
Sivin Varghese
ab0182a9eb fix: ignore Enter while an IME composition is active in the reply box (#16102)
# Pull Request Template

## Description


Agents using an input method editor (Vietnamese Telex, Chinese,
Japanese, Korean) press Enter once to confirm the word being composed
and again to send. The reply box treated the first Enter as send, so the
full message was sent, the editor cleared, and the browser inserted the
half-finished word back into the empty box. The second Enter then sent
that fragment as a separate message.

The reply box now ignores Enter and Cmd/Ctrl+Enter while an IME
composition is in progress, so only the second Enter sends the message.


Fixes
https://linear.app/chatwoot/issue/CW-8345/reply-box-sends-the-message-when-enter-confirms-an-ime-word-then-the

## Type of change

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

## How Has This Been Tested?

### How to reproduce


1. Switch to an IME input source, for example Vietnamese Telex on macOS,
and set the send key to Enter.
2. Type a sentence and stop while the last word is still underlined.
3. Press Enter once. Before this change, the whole message is sent and
the word reappears alone in the editor. After this change, nothing is
sent and the word is confirmed.
4. Press Enter again. The complete message is sent once.
## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-10-06 16:28:42 -07:00
Vishnu Narayanan
d79c052624 perf: count assignment rate limits with a sorted set instead of scanning the keyspace (#16095)
## Description

`AutoAssignment::RateLimiter` counted an agent's recent assignments with
`Redis::Alfred.keys_count`, which runs `SCAN` over the whole keyspace
once per candidate agent per conversation on every assignment run. On a
large keyspace that is thousands of round trips per check, and the cost
scales with keyspace size rather than with assignments.

This stores assignments in one sorted set per inbox and agent (member:
conversation id, score: assignment time), trims entries older than the
window on write, expires the key with the window, and counts with
`ZCOUNT`. Limits, windows and defaults are unchanged.

Existing per-assignment keys are not migrated: windows in progress at
deploy time reset, and the old keys expire on their own.

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

## Type of change

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

## How Has This Been Tested?

`bundle exec rspec spec/services/auto_assignment
spec/jobs/auto_assignment` (62 examples, 0 failures). The rate limiter
spec now asserts Redis state directly, including trimming and the
sliding window via `travel_to`.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-10-07 04:44:10 +05:30
Vishnu Narayanan
5f21903a7b chore: guard against migration version collisions (#16143)
## Description

Migrations with hand-written, rounded versions (e.g. `20260925000000`)
keep colliding across branches. Git merges two such files cleanly
because the filenames differ, and spec CI stays green because it loads
the schema instead of running migrations. The failure only shows up on a
real `db:migrate`: a `DuplicateMigrationVersionError`, or a database
that already recorded one branch's migration silently skipping the
other.

This adds:
- A `Migration Version Check` workflow on PRs that touch `db/migrate`.
It fails when a migration added or renamed in the PR has a version
ending in `0000`, and when two files in `db/migrate` share a version.
Existing migrations are not flagged.
- An `AGENTS.md` rule to create migrations with `rails g migration`
instead of writing the version by hand.

Linear: https://linear.app/chatwoot/issue/INF-118

## Type of change

CI and contributor guidelines only, no app code changes.

## How Has This Been Tested?

Ran both steps locally against simulated PR merge commits on top of
develop: a generated timestamp passes, `...000000` and `...090000`
versions fail, renaming a migration to a rounded version fails, and a
duplicate of an existing version fails. Develop as-is passes both
checks.

## 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
- [ ] 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
2026-10-06 14:17:48 -07:00
Shivam Mishra
f9cbacaa9e perf: skip the account lookup when reading online users (#16131)
Presence pings from the dashboard, mobile app, and widget now run one
less Postgres query each. Agents and visitors see no change in behavior.

## What changed

- `OnlineStatusTracker.get_available_user_ids` loaded the `Account` row
only to reach `account_users`. It now queries `AccountUser` by
`account_id` and plucks the user ids.
- `OnlineStatusTracker.get_availability_from_db` gets the same change.
Its account lookup was served by the query cache until now, and would
have become a real query once the first lookup was gone.

## Why

`RoomChannel#update_presence` calls `get_available_user_ids` on every
ping. On Chatwoot Cloud that was one `Account` query per ping, about
367M a week, averaging 2.9 ms each as measured from the app. The method
was written for auto-assignment, where one extra query per assignment
costs little.

Auto-assignment and inbox agent availability call the same method and
return the same results as before.

<details>
<summary>Benchmark</summary>

Based on local benchmarking of 40,000 pings over 5 threads, the change
cuts Postgres queries from 2 per ping to 1. Throughput rose from about
1,050 pings a second to between 1,460 and 1,600. Median ping time fell
from 4.1 ms to 2.8 ms.

The run used the production traffic mix of 7% agent pings and 93% widget
pings. It ran against a local database and Redis in development mode, so
absolute times in production will differ.

### Script

Save it as `presence_concurrent.rb` and run it with `bundle exec rails
runner presence_concurrent.rb`. `TOTAL` and `THREADS` set the number of
pings and threads. It reads Postgres only, and it writes presence
heartbeats to the local Redis. The `Before` module is a copy of the
method on `develop`, so both versions run in one process.

```ruby
# Runs presence pings from several threads at once, before and after the change.
# Reads Postgres only. Writes presence heartbeats to the local Redis, as a real ping does.
TOTAL = (ENV['TOTAL'] || 40_000).to_i
THREADS = (ENV['THREADS'] || 5).to_i
AGENT_SHARE = 7 # percent of pings that come from agents, as measured in production

# The method as it is on develop
module Before
  def self.get_available_users(account_id)
    user_ids = get_available_user_ids(account_id)
    return {} if user_ids.blank?

    user_availabilities = ::Redis::Alfred.hmget(OnlineStatusTracker.status_key(account_id), user_ids)
    user_ids.map.with_index { |id, index| [id, (user_availabilities[index] || OnlineStatusTracker.get_availability_from_db(account_id, id))] }.to_h
  end

  def self.get_available_user_ids(account_id)
    account = Account.find(account_id)
    range_start = (Time.zone.now - OnlineStatusTracker::PRESENCE_DURATION).to_i
    user_ids = ::Redis::Alfred.zrangebyscore(OnlineStatusTracker.presence_key(account_id, 'User'), range_start, '+inf')
    user_ids += account.account_users.where(auto_offline: false)&.map(&:user_id)&.map(&:to_s)
    user_ids.uniq
  end
end

PG_QUERIES = Concurrent::AtomicFixnum.new
ActiveSupport::Notifications.subscribe('sql.active_record') do |*, payload|
  PG_QUERIES.increment if !payload[:cached] && payload[:name] != 'SCHEMA' && payload[:name].present?
end

accounts = Account.where(id: Conversation.distinct.pluck(:account_id)).to_a
users = accounts.flat_map { |a| a.account_users.pluck(:user_id).map { |id| [a.id, 'User', id] } }
contacts = accounts.flat_map { |a| a.contacts.limit(3000).pluck(:id).map { |id| [a.id, 'Contact', id] } }
random = Random.new(42)
work = Array.new(TOTAL) { random.rand(100) < AGENT_SHARE ? users.sample(random: random) : contacts.sample(random: random) }
puts "accounts=#{accounts.size} users=#{users.size} contacts=#{contacts.size} threads=#{THREADS} db_pool=#{ActiveRecord::Base.connection_pool.size} env=#{Rails.env}"

# One ping, as RoomChannel#update_presence runs it inside the Rails executor
ping = lambda do |tracker, account_id, type, id|
  Rails.application.executor.wrap do
    OnlineStatusTracker.update_presence(account_id, type, id)
    data = { account_id: account_id, users: tracker.get_available_users(account_id) }
    data[:contacts] = OnlineStatusTracker.get_available_contacts(account_id) if type == 'User'
    data
  end
end

run = lambda do |tracker|
  queue = Queue.new
  work.each { |item| queue << item }
  latencies = Array.new(THREADS) { [] }
  PG_QUERIES.value = 0
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  THREADS.times.map do |index|
    Thread.new do
      loop do
        item = begin
          queue.pop(true)
        rescue ThreadError
          break
        end
        t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
        ping.call(tracker, *item)
        latencies[index] << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
      end
    end
  end.each(&:join)
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
  all = latencies.flatten.sort
  { elapsed: elapsed, rate: TOTAL / elapsed, avg: all.sum / all.size, p50: all[all.size / 2], p95: all[(all.size * 0.95).to_i],
    p99: all[(all.size * 0.99).to_i], pg: PG_QUERIES.value }
end

work.first(500).each { |item| ping.call(Before, *item) && ping.call(OnlineStatusTracker, *item) }

[['before', Before], ['after', OnlineStatusTracker], ['before', Before], ['after', OnlineStatusTracker]].each do |label, tracker|
  r = run.call(tracker)
  puts format('%-7s %6.1f s  %6.0f pings/s  avg %.2f ms  p50 %.2f  p95 %.2f  p99 %.2f  postgres queries %d (%.2f per ping)',
              label, r[:elapsed], r[:rate], r[:avg], r[:p50], r[:p95], r[:p99], r[:pg], r[:pg] / TOTAL.to_f)
end
```

</details>
2026-10-06 14:11:11 -07:00
Shivam Mishra
225b2af051 feat: make the refreshed Captain Overview the default (#15980)
Captain Overview now opens the refreshed dashboard for everyone. This
also upgrades its chart library to @chatwoot/viz 0.1.6.
2026-10-06 14:00:26 -07:00
Sivin Varghese
ccbda8b37f feat: redesign Super Admin panel (#16137)
# Pull Request Template

## Description



Fixes https://linear.app/chatwoot/issue/CW-8369/super-admin-redesign

## Type of change

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



## 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-10-06 13:54:08 -07:00
Pranav
a296aa9191 fix(companies): attach logo on enrichment and label authorless notes as System (#16144)
Enriching a company now also sets its logo. Previously enrichment filled
in the profile (industry, size, location, socials) but left the avatar
untouched, so companies kept a low-resolution favicon or no logo at all,
even after "Company profile refreshed." In the company notes feed, notes
without an author now show as **System** with a gear icon instead of
"Someone".

## How to reproduce

1. Open a company that has a domain but no logo (or only a blurry
favicon), on an account with company enrichment enabled.
2. Click **Enrich**. The profile fields refresh, but the logo does not
change.
3. Open the **Notes** tab for a company that has a note created without
an agent (for example via automation or the API). The author shows as
"Someone" with initials.

## What changed

- `Companies::EnrichmentService` attaches the Context.dev logo as the
company avatar, preferring the square `icon` variant and falling back to
the first logo.
- Automatic enrichment on company creation only fills a missing avatar.
The manual **Enrich** action replaces the existing avatar, matching how
it already overwrites other fields.
- Logos in formats avatars do not accept (such as SVG) are skipped and
the existing avatar is kept.
- Authorless notes in the company notes feed render with the name
"System" and a gear icon.

## Notes

- Companies created before favicon fetching existed still have no avatar
until they are enriched or `rake companies:fetch_missing_avatars` is
run.
- For newly created companies, the favicon job can attach before
automatic enrichment finishes, in which case the favicon stays until
someone clicks **Enrich**.
2026-10-06 09:18:22 -07:00
Muhsin Keloth
52a6adedf8 fix(stripe): log safe details for customer fetch failures (#16153)
Stripe customer lookups currently return `stripe_unavailable` without
enough information to identify the underlying provider failure. This
adds minimal server-side diagnostics for Stripe API and network errors
while keeping the customer-facing response unchanged.

### What changed

Log the error class and, for Stripe API errors, the error code, HTTP
status, and Stripe request ID. Raw response bodies, exception messages,
credentials, and customer data are excluded.

### Things to know

Authentication and OAuth error handling remain unchanged. No migrations,
configuration changes, or feature-flag changes are required.

### How to reproduce

With Stripe connected, a customer lookup that encounters a Stripe API or
network error returns `stripe_unavailable`, but previously did not log
the provider details.

### How to test

In a test environment, trigger a customer lookup with a simulated Stripe
API failure. Confirm the response remains `422` with
`stripe_unavailable` and the server log contains only the safe error
details. Confirm successful customer lookups still display billing
information normally.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-10-06 20:01:48 +04:00
Muhsin Keloth
f1c9e4831b feat(stripe): view customer billing details in conversations (#15765)
Adds a read-only Stripe integration so support agents can view customer
details, subscriptions, and recent invoices directly in the conversation
sidebar without asking a teammate to look them up in Stripe.

Account administrators connect through OAuth and manage the connection
from Settings → Integrations. Super Admin controls the Stripe app
configuration. Sandbox and live connections are supported; Chatwoot's
own subscription billing is unchanged.

### Things to know

- This is an initial read-only integration. Refunds, payment actions,
and webhook-driven updates are not included.
- Customer lookup uses the conversation contact's email. When multiple
Stripe customers match, the agent selects one. Billing data is scoped to
conversations the agent can access.
- Customer matching uses Stripe Search with an exact case-insensitive
email check. Newly created or edited customers may not appear
immediately because of indexing delays. Stripe Search is unavailable to
businesses in India; this integration currently shares that limitation.
See [Stripe Search
limitations](https://docs.stripe.com/search#limitations).
- Connections reuse integration hooks, with encrypted OAuth credentials
and token refresh. No new database table is required. Active Record
encryption must be configured.
- Configure the matching OAuth installation link and developer secret in
Super Admin → Settings → Stripe. The Stripe app must allow the
installation's `/stripe/callback` URL. Each installation uses one
credential pair; changing environments requires reconnecting accounts.
- Disconnecting removes the local credentials. Uninstall the app in
Stripe to revoke provider-side access.
- The sandbox flow has been exercised locally. Live-mode behavior has
automated coverage, but live credentials, an internal live pilot,
deployment, and Marketplace publishing are deferred.

### How to test

1. Configure a sandbox OAuth link and matching developer key in Super
Admin → Settings → Stripe, with Active Record encryption enabled.
2. As an account administrator, open Settings → Integrations → Stripe
and connect a sandbox. Confirm the connected account, environment, date,
and dashboard link.
3. Create a Stripe customer whose email matches a Chatwoot contact, with
a subscription and invoices. Open that contact's conversation and expand
Stripe to see the customer, subscription status, and recent invoice
amounts, dates, and statuses.
4. Check a contact with no email, no matching customer, and multiple
matching customers. The sidebar should show the appropriate empty state
or customer selector.
Use a mixed-case email in Stripe to verify case-insensitive matching,
and edit the contact email with the sidebar open to verify it reloads
and clears the old customer selection. Allow time for Stripe's search
index to update.
5. Confirm agents cannot manage the connection or retrieve Stripe data
through an inaccessible conversation.
6. Disconnect and confirm the sidebar is no longer available.
Live-account verification is a separate rollout step and has not been
performed.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-10-06 18:10:20 +04:00
Shivam Mishra
af1e939467 feat(tools-manifest): add a Tools Catalog to browse and install toolsets [PR-4] (#16034)
Admins can now browse the Captain tools catalog and install toolsets
from it. A new "Tools Catalog" button on the tools page opens a page
listing the toolsets published in
[chatwoot/tools](https://github.com/chatwoot/tools), filterable by
category. Clicking a toolset opens a side panel with its tools, the
configuration it needs, and its README, with an Install button that
opens the install preview from #15997. Toolsets the assistant already
has are marked "Installed", or "Update available" when the catalog has a
newer version. Each toolset shows its author, and toolsets authored by
Chatwoot are listed first.

Toolsets can also be installed from a shareable link,
`/captain/toolsets/install?source=chatwoot/tools/<folder>`. It opens the
dashboard in the user's current account, asks which assistant to install
to when there is more than one, and opens the install preview over that
assistant's tools page.

This is part 4 of a series of PRs that bring toolset manifest import to
Captain, stacked on #15997.

## How to test

1. In Super Admin, enable "Captain Tools Manifest". The Captain Tools
Catalog URL defaults to `https://www.chatwoot.com/captain/tools`;
locally, point it at a deployment that serves the catalog, for example
`https://deploy-preview-322--chatwoot.netlify.app/captain/tools`.
2. On an account with the custom tools plan, open Captain > Tools and
click "Tools Catalog".
3. Switch between category pills and check the cards filter.
4. Click a card, review the tools, configuration and README in the side
panel, then click Install and complete the install.
5. Check you land back on the tools page with the new tools listed, and
that the catalog now marks that toolset as installed.
6. Open `/captain/toolsets/install?source=chatwoot/tools/better-stack`
as an admin. With more than one assistant, pick one, then complete or
cancel the install and check you end up on that assistant's tools page.
7. Turn "Captain Tools Manifest" off and check the Tools Catalog button
is hidden on the tools page.

## Changes

- `CAPTAIN_TOOLS_CATALOG_URL` installation config, exposed to the
dashboard through the global config.
- Tools Catalog page at `/captain/:assistantId/tools/explore` with
category pills and a three column card grid.
- Toolset details side panel with tools, configuration fields, README,
and an Install button for admins.
- Installed and update available badges, backed by a new `GET
captain/tools_manifest/installed` endpoint.
- Shareable install link at `/captain/toolsets/install?source=...`, with
an assistant picker when the account has more than one.
- The "Captain Tools Manifest" setting only hides the Tools Catalog
button; the preview and install APIs still enforce it. The catalog, its
install actions and the install link stay behind the custom tools
paywall.
- Custom tool templates no longer render with `strict_variables`, so `{%
if response.error %}` and `| default:` work on optional fields. Liquid
syntax errors still fail the call.
- Custom tool endpoint URLs may contain Liquid tags (`{% %}`); the
save-time URL check ignores them, and SafeFetch still checks the real
host on every call.
- The catalog is fetched from the browser without credentials, so the
catalog host must allow cross-origin requests.

## Known limitations

- An install link opened while logged out, or in an account with no
assistants yet, does not resume after login or assistant creation.
Opening the link again works.
2026-10-06 19:04:18 +05:30
Pranav
ed7b122d27 feat(slack): refresh thread avatars and label Captain and super admin senders (#16141)
Messages Chatwoot posts into a Slack thread now show who sent them more
clearly:

- Captain's replies are labelled **(Captain)** and use the Captain logo
instead of the generic bot one.
- Contact, agent, bot and system messages get new, sharper avatars in
one consistent style.
- Agents who are also super admins are now labelled **(Agent)**; they
were shown as **(Bot)**.

This is split out of #16129. The borders for private notes and activity
messages are in #16142; the two can merge in either order.

## How to test

1. Connect the Slack integration and start a conversation so a thread is
created in the channel.
2. Let Captain reply in the conversation: in Slack the message shows as
`<assistant name> (Captain)` with the Captain logo. A reply from another
agent bot shows as `(Bot)` with the bot avatar.
3. From the dashboard, reply as an agent without a profile picture, and
send a message as a contact: each shows its new default avatar in the
thread. An agent with a profile picture still shows their own.
4. Reply from the dashboard as an agent who is also a super admin: the
message is labelled `(Agent)`.
5. Resolve or reassign the conversation: the activity message appears in
the thread under **System** with the new system avatar.

Slack caches avatars per message, so only messages posted after this
change show the new ones.

## What changed

- `SendOnSlackService` maps each sender label to an avatar file
explicitly. The avatars live under new file names in
`public/integrations/slack/` so Slack fetches them fresh; `user.png`
stays (redrawn) because the Dyte integration uses it as its default
avatar.
- Agents without a profile picture now get the agent avatar. They were
pointed at `agent.png`, which does not exist.
2026-10-05 16:35:36 -07:00
Pranav
b0559b0234 refactor(slack): extract webhook signature verification into a shared verifier (#16140)
Moves Slack request signature verification out of the events webhook
controller into `Integrations::Slack::SignatureVerifier`, so other Slack
endpoints can reuse it. There is no change in behaviour for the events
webhook.

This is split out of #16129, which adds a second Slack endpoint
(interactive button clicks) that needs the same check.

## What changed

- New `Integrations::Slack::SignatureVerifier` with the signing secret
lookup and the signature check, moved as they were from
`Api::V1::Integrations::WebhooksController`.
- The events webhook controller calls the verifier. It still skips
verification with a warning when no signing secret is configured, and
still returns `401` for a missing, stale or invalid signature.
- `SignatureVerifier.valid?` returns `false` when no signing secret is
configured. The events webhook never reaches that case because it checks
for the secret first; it is there so a caller that requires a secret can
use `valid?` alone.
2026-10-05 16:24:28 -07:00
Vishnu Narayanan
1202b5a2da fix(monitors): raise call limits and preserve quota retry holds (#16132)
Conversation monitors now allow **10,000,000 calls per UTC month** and
**500,000 calls per UTC day**, per account. Allowances count attempted
provider requests, including preview calls, and no longer depend on
estimated token lengths. Pending evaluations retain their quota holds
when new messages arrive and resume after the applicable reset without
retrying ahead of a provider cooldown.

## What changed

- Enforce both call limits using persisted daily usage under the account
lock, so concurrent workers cannot exceed the remaining allowance. Each
attempted provider batch consumes one call, even when it evaluates
multiple monitors or returns a provider error.
- Remove the daily token reservation and reconciliation, which mixed
JSON request bytes with provider input tokens. Keep request-size limits
and provider token/cost telemetry.
- Preserve daily and monthly quota holds across new conversation input.
Spread retries over up to four hours after the later of the quota reset
and the longest provider cooldown recorded across failed batches.
- Report when the current monthly allowance was reached, ignoring
timestamps from the previous 100,000-call allowance.
- Document the RubyLLM bundle-audit exceptions for the pinned Ruby 3.4.4
runtime, with reassessment required when the runtime or supported
dependency versions change.

## Quota logging

When a committed call reaches the daily or monthly account cap, emit one
structured `conversation_monitor_limit_reached` log event for that
period. It includes `account_id`, `period` (`daily` or `monthly`),
`used`, `limit`, and the UTC reset timestamp `resets_at`. Rolled-back
calls and subsequent blocked retries do not emit events. Reaching both
caps in the same call emits one event for each period.

The event uses the existing Rails log forwarding path. In New Relic,
group events by account and period with:

```sql
FROM Log
WITH aparse(message, '%"account_id":*,%') AS account_id,
     aparse(message, '%"period":"*"%') AS period
SELECT count(*)
WHERE message LIKE '%conversation_monitor_limit_reached%'
FACET account_id, period
SINCE 7 days ago
LIMIT MAX
```

## Closes

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

## How to reproduce

1. Exhaust an account's daily allowance, then receive additional
conversation messages before the next UTC midnight. Previously, each
message could pull the held work into an early retry and accumulate
another job at the reset. The work now retains its saved retry time
while recording the new input.
2. Have an earlier provider batch return a long `Retry-After`, then
exhaust the daily or monthly allowance before the next batch. The saved
retry now honors both the allowance reset and the longer provider
cooldown before adding jitter.

## Rollout

Existing quota holds retain their saved retry times; the increased
allowances apply when work next reserves a call. Already-scheduled jobs
from before deployment will still run once.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-10-05 15:48:37 -07:00
Muhsin Keloth
12ce7b73cc feat(shopify): complete merchant onboarding from the app store (#13550)
Merchants installing Chatwoot from Shopify can create an account or sign
in to an eligible existing workspace, then continue to Shopify billing
setup. The installation destination follows them through email
confirmation, invitation acceptance, password reset, Google sign-in,
SAML, and MFA.

This is the final Shopify onboarding PR. Dashboard connections and the
installation foundation have already merged in #16084 and #16085; this
PR targets `develop` and completes the App Store signup and
authentication flows.

### Closes

- Fixes
https://linear.app/chatwoot/issue/CW-6500/support-shopify-app-store-install-flow-for-merchant-initiated
- Fixes
https://linear.app/chatwoot/issue/PLA-170/resolve-shopify-app-store-billing-rejection

### Things to know

- Shopify remains behind its feature flag. Shared signup, login,
confirmation, password-reset, and account-routing code changes in this
PR still require regression coverage with Shopify disabled.
- App Store onboarding uses Shopify billing. Existing Stripe customers
continue connecting through the dashboard flow.
- Google signup rejection preserves the target workspace. Invalid or
claimed installation tokens return a recoverable error, and invitation
password-setup links preserve the installation continuation.
- Account membership creates notification settings within its
transaction and reuses existing settings when an agent is re-added.
Unrelated agent-cleanup, IMAP entitlement, and shared-plan lookup
changes are excluded.
- No Shopify lookup-index migration is included; those indexes are
deferred.
- Shop-scoped locking and database uniqueness enforcement are deferred.
Concurrent signups using separate pending tokens for the same shop can
create duplicate connections and block later App Store launches.
Database transactions and model validation do not prevent this race. The
installation/uninstall race is also deferred. Both are accepted risks,
not fixed issues:
https://github.com/chatwoot/chatwoot/pull/13550#discussion_r4154926009
and
https://github.com/chatwoot/chatwoot/pull/13550#discussion_r4152556108.
- Configure the Shopify app URLs for the target environment. Webhook
testing requires a reachable callback host.

### How to test

1. With Shopify enabled, install from a development store, create an
account, confirm the email, and complete billing setup with a test plan.
Verify the store is connected to the new workspace.
2. Repeat while signed out of an eligible existing workspace. Verify
workspace selection and the installation destination survive
email/password login, Google, SAML, and MFA.
3. Start with an unconfirmed invited administrator, resend confirmation,
accept the invitation, and set a password. Verify onboarding resumes in
the intended workspace.
4. Exercise password reset and confirmation resends from a Shopify
continuation. Verify the installation token or billing destination and
workspace are preserved.
5. Reject a personal Google signup and retry with the correct identity.
Verify the target workspace is retained. Retry with an expired or
claimed installation token and verify a recoverable error appears
instead of a server error.
6. With Shopify disabled, verify ordinary signup, confirmation,
invitation acceptance, login/logout, password reset/change, Google,
SAML, and MFA. Check existing Stripe billing and account access.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-10-05 13:56:55 +04:00
Pranav
351c96a213 feat(companies): add Context.dev enrichment and redesign company pages (#16015) 2026-10-04 08:16:34 +05:30
Sivin Varghese
843385ff3a fix: translate the conversation continuity email body (#16103) 2026-10-01 18:19:23 -07:00
Vishnu Narayanan
80086e5afa fix(conversations): validate assignee and team on conversation create (#16104)
## Description

Conversation create now validates `assignee_id` and `team_id` before
creating the conversation, and returns `422` for values that don't
resolve, in line with how bulk actions handle them. Requests without
these fields behave as before.

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

## Type of change

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

## How Has This Been Tested?

Added request specs for invalid assignee and team values (422, no
conversation created). Existing conversations controller specs 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
2026-10-01 12:44:18 -07:00
Muhsin Keloth
170618b9b6 fix(conversations): restrict bulk assignments to the current account (#16098)
Bulk conversation assignments now accept only users and teams belonging
to the current account. Invalid assignment targets return `422` before
the bulk job is queued, so the request does not partially change
conversation status, labels, or assignments.

Existing assignment removal remains supported: `assignee_id: null`,
`team_id: null`, and the dashboard's `team_id: 0` value. Valid
assignments and other bulk actions keep their current behavior.

### How to test

1. As an agent with access to an inbox, select multiple conversations
and assign an agent and team from the current account. Confirm the
assignments update.
2. Remove the agent and team assignments. Confirm both are cleared.
3. Submit a bulk request with an assignment target outside the current
account or a nonexistent target. Expect `422` and no conversation
changes, including when the request also changes status.
4. Bulk-update conversation status without assignment fields. Confirm it
still works.

The pushed commit was checked against a running local Rails server and
Sidekiq worker with synthetic accounts. All 12 HTTP checks passed,
including rejected requests leaving conversations unchanged.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-10-01 11:18:52 -07:00
Muhsin Keloth
480a8ae2e9 fix(shopify): harden dashboard store connections (#16084)
This draft improves Shopify connections started by administrators from
dashboard settings. It adds expiring OAuth state, feature-gate checks,
store-domain validation, and protection against reconnecting an
installation during uninstall cleanup.

Existing signup, login, password reset, and billing behavior remain
unchanged.

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

### Things to know

- First PR in the Shopify split; merge this before the installation
foundation and signup/authentication changes.
- Shopify remains behind its feature flag.
- Includes dashboard integration and cleanup specs. No database
migrations.
- Browser verification and frontend tests remain outstanding; this draft
is not a merge-readiness confirmation.

### How to test

1. Enable Shopify for a test account and connect a development store
from Settings → Integrations → Shopify.
2. Verify customer orders appear in conversations.
3. Disconnect and reconnect the store.
4. Disable Shopify and verify connection attempts are blocked.
5. Confirm an existing Stripe account retains its billing configuration.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-10-01 10:22:06 +04:00
Sivin Varghese
991741ac14 feat: feature announcements in the dashboard (#16082) 2026-10-01 04:11:14 +05:30
Sony Mathew
1f1146a8d8 feat: trigger automations from monitor matches (#16055)
## Description

Administrators can use an active conversation monitor as an automation
event. They can create a rule from the automation screen or from the
monitor list and report, then see linked rules on the monitor report. A
rule fires when a live public message first makes the conversation
match, whether that message is incoming or outgoing. Replies generated
by another automation remain visible in monitor reporting but cannot
trigger a linked rule; historical scans do not run actions. Pausing or
deleting a monitor disables its linked rules, and resuming the monitor
leaves them disabled until an administrator re-enables them.

## Type of change

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

## Closes

-
[CW-8304](https://linear.app/chatwoot/issue/CW-8304/trigger-automations-from-conversation-monitor-matches)

## How to test

1. Use an account with Reports, Conversation Monitors, and Automations
enabled and a configured monitor provider.
2. Create a monitor, then create an automation with **Monitor matched**
as its event and select that monitor. Confirm only active monitors are
offered.
3. From the monitor list or report, create another automation and
confirm the selected monitor is prefilled. After saving, confirm the
monitor report shows the linked rule.
4. Send a new public incoming or outgoing message that first makes the
monitor match. Confirm the rule's action runs. Send an
automation-generated reply that alone matches the monitor, including
immediately after a customer message, and confirm it records the match
without running the linked rule. Confirm historical scans do not run
actions and the same recorded match is not replayed.
5. Pause the monitor and confirm its linked rules become disabled.
Resume it and confirm the rules stay disabled until explicitly
re-enabled. Deleting a monitor should also disable its linked rules.

## What changed

- Added monitor event validation, account scoping, live activity
attribution, durable delivery records, and action dispatch in
Enterprise.
- Kept automation-generated replies in monitor reporting while excluding
them from the action-eligible message context.
- Preserved live eligibility when a new message is updated before
commit, and isolated delivery inserts so deleting a linked rule cannot
discard the monitor match.
- Added the monitor event picker, rule status and search, monitor
shortcuts, and linked rule list in the dashboard.
- Added coverage for API validation, delivery behavior, lifecycle
changes, and frontend selection and navigation.

## 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 feature works
- [x] New and existing focused unit tests pass locally with my changes


## CI follow-up

- Extended the automation composable spec to cover the monitor event.

---------

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-09-30 15:24:21 -07:00
Tanmay Deep Sharma
26ddaba230 feat(contacts): include contact_type in contact webhook and live-update payloads (#16089)
Webhook payloads and dashboard live updates for a contact now carry its
type: `visitor`, `lead` or `customer`. Since #16077 a visitor's first
conversation sends a `contact_updated` event whose `changed_attributes`
names `contact_type`, but the payload itself did not carry the field.
Now it does, so a consumer can read the new type from the event instead
of fetching the contact again.

## Closes

Part of
[CW-8316](https://linear.app/chatwoot/issue/CW-8316/contacts-contact-type-stale-contact-cleanup-and-opensearch-migration).
Follows up on the review of #16077.

## How to test

- Add an account webhook subscribed to `contact_created` and
`contact_updated`.
- Open the widget on a site as a new visitor and send a message. The
`contact_updated` delivery carries `"contact_type": "lead"`, and its
`changed_attributes` shows the change from `visitor`.
- Create a contact from the dashboard. The `contact_created` delivery
carries `"contact_type": "lead"`.
- Keep the contacts page open in the dashboard while a contact changes.
The live update for that contact carries `contact_type` too.

## What changed

- `Contact#webhook_data` and `Contact#push_event_data` gain
`contact_type`. Both are additive: no existing field changes.
- The REST contact payload is unchanged.
2026-09-30 14:18:52 -07:00
Sivin Varghese
c29e79ca00 feat: collapse the reply box to give more room for reading (#16074) 2026-09-30 23:54:40 +05:30
Tanmay Deep Sharma
66275a535c feat(contacts): classify contacts as leads when they are created or updated (#16077)
Contacts now get the right type as they are created and updated. A
contact becomes a lead when it has an email, a phone number or an
identifier, when it has a conversation (clicking a campaign message
counts, since that opens one), or when it was created on purpose through
the contacts API, the dashboard or an import. Everyone else stays a
visitor, and a lead never goes back to being a visitor.

This is the first phase of making `contact_type` the source of truth for
contacts. Nothing reads the type differently yet, so the contact list,
search, filters and exports behave as before on accounts without
`crm_v2`. Existing contacts are not touched; the backfill is the next
PR.

## Closes

Part of
[CW-8316](https://linear.app/chatwoot/issue/CW-8316/contacts-contact-type-stale-contact-cleanup-and-opensearch-migration).
This is phase 1 of 5, so the issue stays open.

## How to test

There is no UI for the type yet. Read it from the console with
`Contact.find(id).contact_type`.

- Open a site with the widget and do nothing: the contact is a visitor.
- Send a first message from the widget: the contact becomes a lead.
- Click a campaign message in the widget without typing anything: the
contact becomes a lead.
- Call `setUser` with only an identifier: lead.
- Create a contact with only a name, from the dashboard or with `POST
/api/v1/accounts/:id/contacts`: lead.
- Import a CSV whose rows have only a name: every row is a lead.
- Create a contact on an API inbox with only a name: visitor, until its
first conversation.
- Start a conversation from the dashboard with a contact that has no
email, phone or identifier: lead.
- Merge a lead into a visitor: the surviving contact is a lead.
- Remove the email, phone and identifier from a lead: it stays a lead.

## What changed

- `Contacts::SyncAttributes` also promotes a visitor when it has an
`identifier`.
- `Conversation` gets an `after_create` that marks a visitor contact as
a lead. It runs inside the insert's transaction instead of after commit.
The app runs `load_defaults 7.0`, so commit callbacks run in reverse
declaration order, and one that raises skips the remaining commit
callbacks of every record in the transaction, which would drop
`conversation_created` and the first message's callbacks.
- `Api::V1::Accounts::ContactsController#create` builds the contact as a
lead.
- `DataImport::ContactManager` builds new CSV rows as leads.
`Contact.import` does not run save callbacks, so until now every new CSV
contact was stored as a visitor, even with an email.
- `DataImports::Importer` (Freshdesk, Intercom) always imports contacts
as leads and promotes a matched visitor.
- `ContactMergeAction` keeps the higher of the two types.
- No write reads the `crm_v2` flag, and the `contacts` table is
unchanged.

## Things to know

- A contact's first conversation sends one extra `contact_updated` event
to webhooks, the dashboard and the LeadSquared hook. Its
`changed_attributes` names `contact_type`, which the payload itself does
not carry.
- The contact row is updated inside the conversation's transaction, so
it stays locked until that transaction commits. On Facebook that
includes the attachment download.
- Conversations created by the Freshdesk and Intercom importers are
inserted in bulk and do not run the new callback. The importer marks its
contacts as leads directly.
- Accounts with `crm_v2` on already read `contact_type = lead` for the
contact list, so they will see the newly promoted contacts.
- Captain prompts print the contact type. Anyone in a conversation now
reads as a lead there.
2026-09-30 10:43:20 -07:00
breken
03702d176d fix(imap): skip blank Reply-To addresses before normalizing sender (#16020)
Fixes #16019

`MailPresenter#from` previously chose a present `reply_to` array before
checking its entries, then called `downcase` on every entry. `[nil]` is
present in Rails, so a malformed Reply-To address list raises
`NoMethodError`. The IMAP fetch job counts the exception and skips the
message for six hours after three failures.

This removes nil/blank entries before downcasing. A Reply-To with no
usable addresses falls back to From. If neither list has an address, the
presenter returns `[]`, leaving the existing IMAP sender-validity check
to ignore the mail. No changes to the fetch cache or unrelated sender
parsing.

Regression specs cover mixed and empty address lists in the presenter,
plus a synthetic raw-email fixture whose `Reply-To: <"">` parses as
`[nil]`. The IMAP mailbox regression uses that fixture without stubbing
`reply_to` and creates a conversation and message from the valid From
address.

### Related issues

- Linear:
[CW-8301](https://linear.app/chatwoot/issue/CW-8301/imap-mailpresenterfrom-calls-downcase-on-nil-and-the-message-is)
- Sentry:
[CHATWOOT-FAZ](https://chatwoot-p3.sentry.io/issues/7696163347/)
- Sentry:
[CHATWOOT-FEY](https://chatwoot-p3.sentry.io/issues/7708063382/)

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-09-29 19:10:46 -07:00
Sivin Varghese
c7d000564e feat: forward emails from email conversations (#15871) 2026-09-30 05:16:19 +05:30
Vishnu Narayanan
481c6abf90 feat: log super admin impersonation events (#16079)
## Description

Adds logs for super admin impersonation.

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

## Type of change

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

## How Has This Been Tested?

Existing impersonation and session specs pass with the change.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-29 16:02:06 -07:00
Sony Mathew
07921d55d5 feat(billing): limit monitors to Business plans (#16075)
## Description

Conversation monitors are now included with Business and Enterprise
Cloud plans. Captain Classifier remains available on every paid plan,
including Startups. Shopify-billed accounts receive Captain Classifier
on entitled plans; monitor access follows each plan's configured
`conversation_monitors` feature. Existing monitor definitions remain in
place after a downgrade, while the monitor API and workers respect the
updated account flag.

Closes
[CW-8331](https://linear.app/chatwoot/issue/CW-8331/limit-conversation-monitors-to-business-plans-and-above).
Follow-up to
[CW-8305](https://linear.app/chatwoot/issue/CW-8305/enable-conversation-monitors-and-captain-classifier-for-all-paid-plans).

Rollout dependency: update `CHATWOOT_SHOPIFY_PLANS` so only
Business-equivalent Shopify tiers and above include
`conversation_monitors`. After deployment, run the reviewed
Rails-console backfill manually to refresh existing billed accounts. The
script previews changes and preserves explicit
`manually_managed_features` grants. Audit any older direct Super Admin
grants before running it; those grants are not distinguishable from
former plan grants in the feature bit alone.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [x] 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 the Stripe plan matrix, Shopify catalog-based grants,
downgrades, and manual overrides with focused RSpec coverage (84
examples passed).
- Verified that a Startups account loses monitor API access after
reconciliation.
- RuboCop passed for all changed Ruby files; `git diff --check` passed.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] 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-09-29 14:12:04 -07:00
Tanmay Deep Sharma
097155a4af fix(voice): cancel the Twilio call leg when an agent ends a call before pickup (#15961)
Fixes a bug with outbound Twilio calls. If an agent hung up before the
callee answered, the callee's phone kept ringing. If they then picked
up, they heard hold music with nobody on the line. Now hanging up in the
dashboard also hangs up the callee's Twilio call leg. A leg that still
gets answered after the call has ended is sent `<Hangup/>` instead of an
empty conference.

In production, 38 of 289 agent-ended outbound Twilio calls (13.1%) kept
the callee's leg alive after teardown. 22 of them were answered into
hold music, 12 rang on to `no-answer`, and 4 ended `busy`. That comes to
about 49 minutes a month of billed Twilio audio with nobody on our side.

## Closes

-

## How to reproduce

1. Place an outbound call from a Twilio voice inbox to a phone you have
with you.
2. While it is still ringing, hang up in the dashboard.
3. Before this fix, the phone keeps ringing, and answering it plays hold
music. After this fix, it stops ringing right away.

## What changed

- `ConferenceService#terminate_call` hangs up `provider_call_id`, and
also `parent_call_sid` when it is set and different, with
`Status=completed`. It uses `call.inbox.channel.client`, so api-key mode
works. `ConferenceController#destroy` calls it before `end_conference`,
and both run before the call is finalized, so a failure still leaves the
call repairable. `end_conference` stays as it was, because it handles
the agent's leg and any other participants already in a live conference.
- Why `completed` in every state: Twilio documents that "Specifying
`canceled` will attempt to hang up calls that are queued or ringing;
however, it will not affect calls already in progress. Specifying
`completed` will attempt to hang up a call even if it's already in
progress." Sending `canceled` would silently do nothing if the callee
answers while the request is in flight, and that is exactly this bug.
- For a leg that has already ended (the normal case, 251 of 289 calls),
Twilio returns HTTP 200 and leaves the status unchanged. We tested this
against a real account for both status values, on both `completed` and
`no-answer` legs. So no rescue is needed.
- In `Twilio::VoiceController#call_twiml`, if the resolved call is
already terminal, it now renders `<Hangup/>` instead of the conference
TwiML. This covers the race where the callee answers while the hang-up
request is in flight. The `reject_inbound?` path is unchanged.
- This also fixes the August bug where an inbound caller was stuck in
hold music. When the conference was still in `init`, `end_conference`'s
`status: 'in-progress'` query found nothing. `terminate_call` acts on
the Call resource instead, so the conference state no longer matters.

## Out of scope (follow-ups)

- `useCallSession.js#endCall` has no in-flight lock (unlike `joinCall`'s
`globalIsJoining`), so 18% of call_sids get duplicate `destroy` calls.
Those are harmless now, because a hang-up on an ended leg returns 200.
- Outbound calls are marked `in_progress` when the agent joins the
conference, not when the callee answers. So calls nobody answered still
get a "completed" bubble.
- `Voice::Provider::Twilio::Adapter#twilio_client` pairs `account_sid`
with `auth_token`, which is broken in api-key mode.
2026-09-29 12:32:22 -07:00
Vishnu Narayanan
5d495bb919 fix: mint impersonation tokens on click (#15992)
## Description

Impersonation tokens were minted every time a super admin opened a user
page, since the link was rendered into the page. They are now minted
only when a super admin clicks.

- Impersonate user opens the customer dashboard in a new tab, as before.
- Copy link copies a single-use link (valid for 5 minutes) to the
clipboard, for opening in another browser or an incognito window.

Both are authenticated, CSRF-protected POSTs, and the token is bound to
the super admin who clicked.

<img width="1138" height="857" alt="image"
src="https://github.com/user-attachments/assets/86f54dd5-9efa-4c05-a005-257c8ef32a5a"
/>


Depends on #15976.

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

## Type of change

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

## How Has This Been Tested?

Request specs cover: no token minted on page render, a token bound to
the signed-in super admin on both actions, authentication required, and
CSRF rejection.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-29 12:31:03 -07:00
Vishnu Narayanan
5ac8d71537 feat: attribute impersonation to super admins and skip impersonation audit logs (#15976)
## Description

Impersonation links now record which super admin generated them, and the
session they create carries that super admin on its token, so a live
impersonation session can be attributed to a staff member. A durable
staff-side record of impersonation will follow in a separate PR.

This also fixes impersonation showing up in the customer's audit log.
Impersonation sign in and ending it (sign out) wrote regular sign_in and
sign_out entries to the account's audit log, so account admins saw
logins they did not make, from an unfamiliar location. Both are now
skipped for impersonation sessions, the same way session tracking
already skipped them.

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

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Specs cover the super admin binding on the token and session, and the
skipped sign in and sign out audit events.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-29 10:19:22 -07:00
Shivam Mishra
d09576cc08 feat: search and show email subjects on conversations (#16046)
Agents couldn't find email conversations by their subject, and the
subject of outgoing emails wasn't visible anywhere in the conversation.
Conversation search now matches the email subject, and email
conversations show the subject in the header next to the conversation ID
and inbox, with the full text in a tooltip when it's truncated.

Closes
https://linear.app/chatwoot/issue/CW-8228/display-outgoing-email-subjects-in-conversation-view-and-search

### Screenshots

#### RTL

<img width="3140" height="296" alt="CleanShot 2026-09-28 at 14 54 57@2x"
src="https://github.com/user-attachments/assets/37c5482e-ab15-4d6c-a5ec-9b1b5c7d17d7"
/>

#### Simple Subject Line
<img width="2646" height="968" alt="CleanShot 2026-09-28 at 14 51 49@2x"
src="https://github.com/user-attachments/assets/58b7f97a-370c-47a4-a374-b4bfa8818117"
/>

#### Absurdly Long Subject Line
<img width="2646" height="968" alt="CleanShot 2026-09-28 at 14 51 45@2x"
src="https://github.com/user-attachments/assets/81213729-2e74-421d-9e0a-b7ec5b3ee622"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-09-29 20:43:43 +05:30
Muhsin Keloth
a851d63cf2 fix(whatsapp): restore contact info requests for bsuid conversations (#15998)
Agents can request a phone number again from eligible WhatsApp Cloud
conversations identified by a BSUID. The action now checks availability
when an agent opens a conversation whose contact has no phone number,
and refreshes as the contact or request state changes.

Eligibility is fetched separately from conversation rendering, keeping
pending-request history queries out of conversation lists. Existing
checks still prevent duplicate pending requests and require an approved
Request Contact Info template outside the messaging window.

Related:
https://linear.app/chatwoot/project/whatsapp-bsuid-4df5614db3f9/issues

### Background: previous approach

[PR #15826](https://github.com/chatwoot/chatwoot/pull/15826) temporarily
hid the dedicated Request Contact Info action to remove pending-request
message-history lookups from routine conversation rendering, including
inbox lists, filtered lists, contact conversation lists, and individual
conversation responses. These eligibility checks added database work to
ordinary browsing. Checking only the messages loaded in the UI would
also miss pending requests in older history, potentially showing an
action that the backend would then reject.

This PR restores the action through a separate availability request for
the selected WhatsApp Cloud conversation when the contact has no phone
number. The endpoint retains the full pending-request check across
conversations sharing the contact-inbox identity, while keeping history
lookups out of conversation rendering as in #15826. History lookups
still occur for the selected conversation's eligibility check; they are
no longer part of routine list rendering.

### Things to know

- No database migrations or feature flags are required. The unused
static `contact_info_request` field is removed from conversation
responses; the dashboard reads availability from the dedicated endpoint.
External clients consuming that field must use the endpoint instead.
- Availability requests are cancelled when switching conversations, and
a pending request remains disabled even when its message is outside the
loaded message history.
- The interactive request and customer phone-sharing response were
manually verified end to end. The template flow outside the messaging
window has not been tested live.
- Existing targeted validation passed: 29 Ruby service examples, 155
frontend tests, Ruby and JavaScript linting, and focused endpoint,
authorization, and concurrent-request checks.

### How to reproduce

Open a WhatsApp Cloud conversation with a BSUID contact, no phone
number, and an active messaging window. Before this fix, the Request
Contact Info action is hidden because the conversation payload reports
the capability as unavailable.

### How to test

1. Open an eligible WhatsApp Cloud conversation whose contact has no
phone number. Confirm Request Contact Info appears in the composer.
2. Send the request. Confirm the action is disabled while a request is
pending, including after reopening the conversation.
3. Share the phone number from WhatsApp. Confirm the contact is updated
and the action disappears.
4. Switch between conversations while availability is loading. Confirm
the action reflects the selected conversation.
5. Open a conversation outside the messaging window. With an approved
Request Contact Info template, confirm the action opens the template
picker; without one, confirm the action is hidden.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-29 15:46:00 +04:00
Shivam Mishra
8c80026a79 feat(tools-manifest): install Captain toolsets from GitHub [PR-3] (#15997)
Adds the API and install dialog for installing Captain toolsets from the
[chatwoot/tools](https://github.com/chatwoot/tools) repository. The
dialog takes `chatwoot/tools/<folder>`, previews the toolset and its
tools, asks for the inputs and secrets it declares, and installs the
latest commit from the default branch. If an older version is installed,
it offers to update it. Installed tools show where they came from and
are read-only, apart from enabling or disabling them. The dialog has no
entry point on its own; the Tools Catalog in the next PR opens it.

Installs require the `custom_tools` plan and a new Super Admin switch,
"Captain Tools Manifest", which is off by default.

This is part 3 of a series of PRs that bring toolset manifest import to
Captain, following #15996 (merged).

## Changes

- Preview and install API for toolsets in chatwoot/tools, for
administrators.
- Short, translated error messages for bad sources and configuration
issues.
- Install dialog, opened by the Tools Catalog in the next PR.
- Installed tools show their source, open read-only, and can only be
enabled or disabled.
- Tool titles open the side panel: editable for admins' own tools,
read-only otherwise.
2026-09-29 15:55:21 +05:30
Shivam Mishra
4ad74f2ab4 feat(tools-manifest): add toolset manifest install service [PR-2] (#15996)
Adds the backend for installing Captain toolsets from the
[chatwoot/tools](https://github.com/chatwoot/tools) repository. A
toolset is addressed as `chatwoot/tools/<folder>` and pinned to a
commit, either one passed in or the latest on the default branch.
Installing the same commit again is a no-op, and a new commit updates
the existing tools in place while keeping their slugs and the admin's
enabled or disabled choice. Nothing calls these services yet; the API
and UI come in the next PRs.

This is part 2 of a series of PRs that bring toolset manifest import to
Captain, following #15995 (merged).

## Changes

- Custom tools record their source (repository, folder, tool id, commit,
version).
- Install service that fetches a toolset from chatwoot/tools and
installs it onto an assistant.
- Toolsets are validated before they are merged into chatwoot/tools, so
Chatwoot only parses the manifest and fills in defaults. Every installed
tool still goes through the custom tool model's validations (headers,
endpoint safety, auth config), and the admin's install values are
checked for type and required fields.
- Optional internal GitHub token for commit lookups, with fallback when
it's rejected.
2026-09-29 15:18:26 +05:30
Shivam Mishra
8cf0b4e04f feat(tools-manifest): add static headers to Captain custom tools [PR-1] (#15995)
Captain custom tools can now send static HTTP headers with every
request, for APIs that expect something like `cal-api-version` or a
tenant ID. Header rules match the tools catalog manifest schema, so
manifests the catalog accepts will import cleanly. Tools can also use
PUT, PATCH and DELETE, which the manifest allows. The tool form moves to
a side panel, with collapsible sections for headers, parameters and
templates.

This is part 1 of a series of PRs that bring toolset manifest import to
Captain.

## Changes

- Static headers on custom tools, following the catalog manifest rules.
- PUT, PATCH and DELETE support for custom tools.
- Test button now applies the same validation as saving a tool.
- Redesigned tool form in a side panel.
2026-09-29 14:30:29 +05:30
Vishnu Narayanan
d4b54412a8 fix: drop the contact_inboxes foreign key on campaign_recipients (#16060)
## Description

`20260922090000_add_contact_inbox_to_campaign_recipients` added the
`contact_inbox_id` column together with a database foreign key to
`contact_inboxes`, in a single transaction. The column add takes an
`ACCESS EXCLUSIVE` lock on `campaign_recipients`, and the foreign key
then needs a lock on `contact_inboxes`. Any in-flight transaction that
has written to `contact_inboxes` and then reads `campaign_recipients`
ends up waiting on the migration while the migration waits on it, so
Postgres aborts the migration with `PG::TRDeadlockDetected`. On a busy
install this happens on every attempt.

We manage associations in Rails rather than with database foreign keys
(see
https://github.com/chatwoot/chatwoot/pull/8602#discussion_r1461694017),
so this drops the foreign key:

- `20260922090000` now only adds the column and index. It touches a
single table, so it cannot deadlock.
- `CampaignRecipient` already declares `belongs_to :contact_inbox,
optional: true`, so no model change is needed.
- `AGENTS.md` documents the foreign key convention.

Follow-up to https://github.com/chatwoot/chatwoot/pull/15854

## Type of change

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

## How Has This Been Tested?

- Fresh database: rolled the migration back and ran it again. The column
and index exist and there is no foreign key to `contact_inboxes`.
- Reproduced the original deadlock in plain SQL with a concurrent
transaction that writes `contact_inboxes` and then reads
`campaign_recipients`; the column-only migration does not lock
`contact_inboxes` at all.

## 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
- [ ] 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-09-29 01:04:36 -07:00
Sony Mathew
e2de952fad feat(reports): add monitor templates (#16030) 2026-09-29 05:31:31 +05:30
Sivin Varghese
178700dc2b feat: add Captain conditions to automation rules (#16032) 2026-09-29 05:27:29 +05:30
Sivin Varghese
ac7ee4cac5 feat: apply a macro to multiple conversations at once (#16027) 2026-09-29 04:58:14 +05:30
Aakash Bakhle
e8cd72889c feat(captain): trace Jev calls in Langfuse through a shared client (#16031)
Captain label and priority suggestions and conversation monitors call
Jev outside RubyLLM. Those requests were missing from Langfuse.

<img width="1732" height="1526" alt="CleanShot 2026-09-28 at 12 07
01@2x"
src="https://github.com/user-attachments/assets/ce8735af-c360-4373-91d4-4e01822e65c0"
/>


## What changed

- Route both features through one System One HTTP client.
- Record a Langfuse generation with the complete request and response,
served model, reported token usage, cost, account, and conversation
session.
- Keep monitor credit accounting, response validation, scoring, and
error mapping in its decision service.
- Mark Jev's remote call as an OpenTelemetry client span and record its
operation, returned model, conversation ID, and low-cardinality failure
type.

## Langfuse v4 and OpenTelemetry review

The existing exporter already uses OTLP/HTTP at
`/api/public/otel/v1/traces`, Basic Auth, and
`x-langfuse-ingestion-version: 4`. Each Jev call exports one completed
generation span with its full request and response on the observation.
The v4 migration does not require a new Langfuse API or SDK here. The
new Jev fields follow the current OpenTelemetry GenAI client-span
guidance while keeping the stable feature-specific span name.

Jev's reported token counts are copied from its response. The full HTTP
request body is captured, but Jev does not expose any internal
representation it may build from that body. Higher token counts do not
establish a specific hidden prompt or overhead.

## Verification

- 103 focused RSpec examples passed for the shared Jev implementation;
the four Jev client examples pass after the OpenTelemetry follow-up.
RuboCop passes on the changed Ruby files.
- Synthetic classifier and monitor calls succeeded locally. Langfuse
returned one `GENERATION` observation for each call, with the request,
response, model, usage, and cost.

References: [Langfuse v4 custom
ingestion](https://langfuse.com/integrations/native/opentelemetry/migration-to-v4),
[Langfuse OTEL
mapping](https://langfuse.com/integrations/native/opentelemetry),
[OpenTelemetry GenAI client
spans](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-spans.md).
2026-09-28 14:45:16 -07:00
Tanmay Deep Sharma
35b6156333 fix: update rack-proxy for security advisory (#16059)
Updates `rack-proxy` to a patched release so the `bundle-audit` step in
CI passes again. The refreshed advisory database flags the version in
our lockfile, which currently fails the `security-scan` job on every
open PR. `rack-proxy` is only used by the Vite dev server proxy, which
is mounted in development and local test runs, so the app's request
handling in production is not affected.

Fixes: GHSA-42qh-8mx8-7wqm

## How to reproduce

On `develop`, run:

```
bundle exec bundle-audit update
bundle exec bundle-audit check
```

It reports `rack-proxy 0.7.7` with the solution `upgrade to '>= 1.0.3'`
and exits with a non-zero status.

## What changed

- Updates `rack-proxy` from 0.7.7 to 1.0.3, the patched version named in
the advisory.
- Updates `vite_ruby` from 3.10.2 to 3.10.6. `rack-proxy` is a
transitive dependency of `vite_ruby`, and 3.10.2 pins it to `~> 0.6`.
Support for `rack-proxy` 1.0 landed in 3.10.5.
- Keeps `vite_ruby` on the 3.10 line on purpose. 3.11.0 detects the dev
server by reading a `tmp/vite-ruby.json` file written by a newer
`vite-plugin-ruby` than the 5.2.1 we use, so it needs to be taken
together with a JS dependency update.
- Only `Gemfile.lock` changes. No other gems are updated.
2026-09-28 14:04:21 -07:00
Muhsin Keloth
0b7a26f449 feat(whatsapp): add campaign history, batch sending, and paid-plan analytics (#15854)
Show sent WhatsApp campaigns in the conversation timeline so agents can
see what the contact received. Send large campaigns in smaller batches,
and make campaign analytics available on Business and Enterprise Cloud
plans.

### What changed

- Display campaign content, title, timestamp, and delivery status in
neutral message bubbles.
- Scope history to the contact, WhatsApp inbox, and conversation time
window so saved campaign sends appear in the conversation active when
they were sent, even when contact-inbox identities differ.
- Process campaign audiences in batches of 100.
- Keep live chat, SMS, and WhatsApp campaigns available on all paid
Cloud plans through the shared campaigns feature flag.
- Show an analytics upgrade prompt on Startups; Hacker accounts do not
have campaign access.
- Warn that deleting a campaign also removes its saved history and
delivery details.

### Things to know

- History starts at the selected conversation’s creation time and ends
before the next conversation for the same contact and inbox. Campaigns
sent before the conversation started are excluded.
- The change does not backfill historical sends that have no saved
campaign-recipient record.
- Replies are not explicitly attributed to campaigns.
- Analytics reflect progress so far while a campaign is sending.
- Durable resume and duplicate-send prevention remain follow-up work.
Retried batches can resend messages on Community Edition; concurrent
jobs or interruption between provider acceptance and saving delivery
status can also cause duplicates.

### How to test

1. Open a WhatsApp conversation, then send a campaign to that contact.
Reopen the conversation and confirm the campaign appears with its
content, title, timestamp, and delivery status.
2. Repeat with a contact that has multiple WhatsApp identities in the
same inbox. Confirm the campaign appears in the contact-and-inbox
conversation active when it was sent.
3. Open two conversations for the same contact and inbox. Confirm each
shows campaigns from its start time until the next conversation begins,
and that earlier campaigns are excluded.
4. Load earlier campaign history and switch conversations; check
ordering and scroll position.
5. Send a campaign to more than 100 contacts and confirm processing
continues through subsequent batches.
6. Confirm Business and Enterprise accounts can access analytics. On
Startups, confirm campaigns remain available and analytics shows an
upgrade prompt. On Hacker, confirm campaign pages are unavailable.
7. Check the campaign deletion warning.

Related:
https://linear.app/chatwoot/issue/CW-7891/batch-and-resume-large-campaign-sends

Related:
https://linear.app/chatwoot/issue/CW-7825/persist-whatsapp-campaign-messages-and-attribute-replies-to-campaigns

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-09-28 15:56:28 +04:00
Vishnu Narayanan
b8eb7766f2 chore: post ghsa sync notifications to slack (#16029)
# Pull Request Template

## Description

The GHSA to Linear sync posts a notification per new advisory to a
Discord webhook. This adds an equivalent Slack notification.

https://linear.app/chatwoot/issue/INF-111

## Type of change

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

## How Has This Been Tested?


## 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
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-25 15:41:57 -07:00
Sony Mathew
0d17437200 chore: enable monitors and Captain Classifier for paid plans (#16026)
Conversation Monitors and Captain Classifier will be available to every
paid Chatwoot Cloud account, including Stripe Startups, Business, and
Enterprise customers and paid Shopify App Pricing customers. Existing
accounts need the manual backfill below; future plan changes use billing
reconciliation.

## Closes

-
[CW-8305](https://linear.app/chatwoot/issue/CW-8305/enable-conversation-monitors-and-captain-classifier-for-all-paid-plans)
— paid-plan entitlement rollout, child of
[CW-8270](https://linear.app/chatwoot/issue/CW-8270/conversation-monitors-to-reports-with-jev)

## Type of change

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

## What changed

- Grant `conversation_monitors` and `captain_classifier` from the Stripe
Startups tier upward; Business and Enterprise inherit both. Downgrades
to Hacker revoke plan-managed grants unless the account has a manual
grant.
- Grant both flags to paid Shopify App Pricing accounts during billing
reconciliation, alongside their catalog features. Shopify subscription
loss removes the plan-managed flags; the catalog continues to manage
other Shopify entitlements.
- Keep both flags available for manual grants in Super Admin.
- Restrict the minute-by-minute monitor dispatcher to accounts with
active monitors so broader eligibility does not scan every paid account.

## Manual rollout

Run this one-off script in the **Chatwoot Cloud Rails console before
deploying this PR**. It records existing pilot grants as manual
overrides before the new reconciliation rules can remove them, then
enables both flags for existing paid accounts. It is safe to rerun.

```ruby
raise 'Chatwoot Cloud only' unless ChatwootApp.chatwoot_cloud?

features = %w[conversation_monitors captain_classifier]
plans = %w[Startups Business Enterprise]

features.each do |feature|
  Account.public_send("feature_#{feature}").find_each(batch_size: 100) do |account|
    next if account.billing_provider == 'shopify' && Array(account.internal_attributes['shopify_managed_features']).include?(feature)

    manual = Array(account.internal_attributes['manually_managed_features'])
    next if manual.include?(feature)

    account.internal_attributes = account.internal_attributes.merge('manually_managed_features' => manual | [feature])
    account.save!(validate: false)
  end
end

stripe = Account.where("custom_attributes->>'plan_name' IN (?)", plans)
                .where("COALESCE(NULLIF(internal_attributes->>'billing_provider', ''), 'stripe') = 'stripe'")
shopify = Account.where("internal_attributes->>'billing_provider' = 'shopify'")
                 .where("custom_attributes->>'plan_name' <> ''")

[stripe, shopify].each do |scope|
  scope.find_each(batch_size: 100) do |account|
    next if account.billing_provider == 'shopify' && !Shopify::FeatureGate.enabled?(account: account)

    missing = features.reject { |feature| account.feature_enabled?(feature) }
    next if missing.empty?

    account.enable_features(*missing)
    account.save!(validate: false)
  end
end
```

After the backfill, deploy this PR. New upgrades and downgrades are
handled by the billing reconciler. No database migration is included in
the PR.

## Rollout notes

- Confirm `CAPTAIN_OPENROUTER_API_KEY` and
`CAPTAIN_OPENROUTER_DECISION_MODEL_ENDPOINT` are configured before
enabling Captain Classifier for customers. The classifier calls
OpenRouter directly and currently has no per-account credit metering or
quota; monitor usage limits do not cover classifier requests. Check
expected traffic and provider costs before rollout.

## How to test

1. On Chatwoot Cloud, confirm Hacker has neither feature without a
manual grant. Upgrade a Stripe account to Startups, Business, and
Enterprise; both features should be available at each paid tier.
2. Downgrade the Stripe account to Hacker; both plan-managed flags
should become unavailable. A previously granted pilot account should
retain its manual grants.
3. After the manual backfill, confirm existing paid accounts have both
flags without waiting for another billing event. Open Reports → Monitors
and check Captain Classifier suggestions in a conversation.
4. Confirm a paid Shopify Basic or Pro account receives both flags even
if the Shopify catalog omits them, and loses them when its subscription
ends. Other Shopify features continue to follow the catalog.
5. Confirm a self-hosted account is unchanged.
2026-09-25 15:37:40 -07:00
Sojan Jose
f9cf455b06 feat: add AI conversation reply takeover (#15438)
Extends the existing AgentBot reply lock and **Take over** action to
Captain-owned pending conversations. Private notes remain available.

Closes:
https://linear.app/chatwoot/issue/CW-7453/block-replies-and-add-takeover-for-captain-ownership

## Why

Captain assignment is available, but the dashboard composer needs to
respect Captain ownership in the same way it respects AgentBot
ownership.

## What this change does

- Blocks dashboard public replies and templates while an AI assignee
owns a pending conversation. Macros retain their existing behavior.
- Uses the existing assignment flow for Take over: open the pending
conversation, assign the current agent, and clear AI ownership. No new
API parameter is added.

The messages API is unchanged; backend enforcement is tracked in
[CW-7779](https://linear.app/chatwoot/issue/CW-7779/assess-and-enforce-backend-reply-blocking-for-ai-owned-conversations).
Captain entitlement and inactive-owner cleanup are tracked in
[CW-8285](https://linear.app/chatwoot/issue/CW-8285/gate-captain-ownership-on-entitlement-and-clear-inactive-ownership-on);
queued auto-resolution is tracked separately in
[CW-8218](https://linear.app/chatwoot/issue/CW-8218/stop-captain-auto-resolution-when-entitlement-is-disabled).

## Validation

- On pending AgentBot- and Captain-owned conversations, check public
replies, templates, private notes, and Take over.
- Confirm Take over opens the conversation, assigns the agent, and
unlocks public replies; non-pending composer behavior remains unchanged.
2026-09-25 15:23:32 -07:00
Sivin Varghese
37164f874e chore: add events for label and priority suggestions (#16028) 2026-09-26 03:49:24 +05:30
Sivin Varghese
cf90f4e51d chore: add icons to conversation monitors (#16016)
# Pull Request Template

## Description

This PR adds icons to conversation monitors, making them easier to
identify in the list. Each new monitor gets a random icon and color in
either outline or filled style.

Admins can change the icon, color, or emoji when creating or editing a
monitor using the same picker used in Teams. The selected icon is shown
in the monitors list and next to the monitor name on its report page.

## Type of change

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

## How Has This Been Tested?

### Screenshots
<img width="580" height="647" alt="image"
src="https://github.com/user-attachments/assets/b97761c4-72bc-4573-9cf5-8648ede04be0"
/>



## 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: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-09-25 14:49:11 -07:00
Sony Mathew
2cfe79ec1e feat(reports): add common monitor templates (#16011)
The empty Monitors page keeps its centered heading, create action, faded
preview, and compact template list. It now offers four familiar customer
conversation topics: missing order updates, feature requests, refund
requests, and cancellation intent. Selecting one prefills its name and
full condition. The preview uses example text without invented counts,
and the WhatsApp BSUID and self-hosted automation examples are removed.

Related to #15966.

## How to test

1. Open Reports → Monitors in an account with no monitors. Confirm the
existing centered empty-state layout and four compact template rows
appear.
2. Select Missing order updates or Feature requests. Confirm the
creation form contains the corresponding name and full condition.
3. Confirm Login problems, Competitor mentions, WhatsApp BSUID, and
self-hosted automation examples are absent, and the faded preview shows
no fabricated counts.
4. Sign in without administrator access. Confirm the templates are
disabled and the administrator help text appears.
2026-09-25 12:58:28 -07:00
Vishnu Narayanan
fcadd60492 feat: move mfa enforcement setting to the security settings page (#16013) 2026-09-26 00:56:15 +05:30
Sony Mathew
1161aa651d fix(reports): keep monitor drill-down open on refresh (#16012)
When someone is reviewing a monitor's conversation drill-down,
background chart updates now leave the drawer and its displayed chart
snapshot in place. The chart catches up when the drawer closes, so
rolling bucket boundaries and incoming matches no longer dismiss an
active review.

Related to #15966.

## How to reproduce

1. Open a monitor with a nonempty chart bucket and select that bucket.
2. Leave the drill-down open through the next automatic refresh or a
monitor update event. The drawer stays open on the same snapshot.
3. Close the drawer. The chart fetches the latest results.
2026-09-25 11:58:04 -07:00
Aakash Bakhle
685abc72d9 feat: run Captain Playground responses in worker (#15845)
## Summary

1. Return HTTP 202 after queuing a Playground response job.
2. Run Captain V2 Playground generation in Sidekiq and remove the legacy
Playground branch.
3. Deliver the result or error to the requesting user through Action
Cable.
4. Match responses by request ID and ignore stale results after a reset.
5. Keep Playground runs conversationless so conversation actions cannot
change a conversation.
6. Remove the dashboard's V1 fallback and always initialize the V2 test
setup.

## Validation

1. Focused Rails request, job, configuration, and run-detail specs
passed with 54 examples.
2. Playground and Action Cable frontend specs passed with 26 tests.
3. RuboCop, ESLint, Prettier, and git diff check passed.
4. A live Cook run completed in Sidekiq in 2.922 seconds, broadcast the
WebSocket event, and displayed `WebSocket V2 works` with run details.
5. The Playground and V2 test setup rendered correctly at desktop and
mobile sizes.

Linear:
https://linear.app/chatwoot/issue/AI-213/playground-should-work-via-websocket

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-09-25 11:53:49 -07:00
Sivin Varghese
fa57193fe0 fix: automation form crash when switching the event (#16014) 2026-09-25 11:36:12 +05:30
Vishnu Narayanan
2a65bede33 feat: add email delivery checks to the super admin panel (#15989)
## Description

Adds an email delivery diagnostic to the super admin user page.

When an address hard-bounces or gets a spam complaint, SES adds it to
the account-level suppression list and silently drops every later email
to it. The app still logs the send, so nobody sees a failure. This adds
an **Email** menu on the super admin user page with:

- **Check email delivery**: looks up the address on the SES suppression
list and shows whether it is blocked, why (bounce or spam complaint) and
since when.
- **Unblock email**: removes a bounce entry after an in-app
confirmation. Disabled for spam complaints, which need engineering.
- **Send test email**: sends a plain-text email through the regular
mailer path to confirm delivery end to end.
- **Resend confirmation email**: the existing action, moved into the
menu for unconfirmed users.

Unblock and test email write a structured log line with the super admin
and target user. Results show as a toast under the header actions on
this page, so the page does not shift.

The feature is inert unless `SES_SUPPRESSION_ROLE_ARN` is set: the
actions return 404 and the page keeps the existing standalone resend
confirmation button. SES calls assume that role from the instance
profile, so the storage keys are never used. `SES_SUPPRESSION_REGION`
overrides the default `us-east-1`. Adds the `aws-sdk-sesv2` gem.


<img width="784" height="390" alt="image"
src="https://github.com/user-attachments/assets/65f87198-1bb7-4523-8d74-94d12e4e3003"
/>


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

## Type of change

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

## How Has This Been Tested?

- Service, mailer and super admin controller specs, with SES stubbed
through the SDK.
- Ran locally with SES responses mocked and checked each state (not
blocked, bounce, spam complaint, unblock, test email, unconfirmed user)
in the browser.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-24 18:59:58 -07:00
Sony Mathew
f052f667c4 feat(reports): define monitor data contracts (#15966)
## Description

Create the disabled-by-default data foundation for conversation
monitors. One reversible migration creates the five final tables for
monitors, conversation decisions, unified historical/resume scans,
durable work, and daily usage. The optional `user_id` identifies the
user who created the monitor; account ownership remains separate. All
schema changes live in this PR, with no follow-up migrations in the
later stack.

**Review question:** Does the schema preserve account boundaries and
durable lifecycle state?

## Closes

Part of
[CW-8270](https://linear.app/chatwoot/issue/CW-8270/add-conversation-monitors-to-reports-with-jev).
The full stack delivers the feature.

## Stack

**1 of 10**. Base: `develop`. Next:
[15967](https://github.com/chatwoot/chatwoot/pull/15967).

| Order | Draft PR | Review scope |
| --- | --- | --- |
| 1 | [#15966](https://github.com/chatwoot/chatwoot/pull/15966) | define
monitor data contracts |
| 2 | [#15967](https://github.com/chatwoot/chatwoot/pull/15967) |
enforce monitor call credits |
| 3 | [#15968](https://github.com/chatwoot/chatwoot/pull/15968) |
evaluate monitor conditions through OpenRouter |
| 4 | [#15969](https://github.com/chatwoot/chatwoot/pull/15969) |
process durable monitor evaluations |
| 5 | [#15970](https://github.com/chatwoot/chatwoot/pull/15970) | track
monitor source activity |
| 6 | [#15971](https://github.com/chatwoot/chatwoot/pull/15971) |
support monitor lifecycle transitions |
| 7 | [#15972](https://github.com/chatwoot/chatwoot/pull/15972) | expose
conversation monitor APIs |
| 8 | [#15973](https://github.com/chatwoot/chatwoot/pull/15973) |
display live monitor reports |
| 9 | [#15974](https://github.com/chatwoot/chatwoot/pull/15974) | create
and preview conversation monitors |
| 10 | [#15975](https://github.com/chatwoot/chatwoot/pull/15975) |
manage conversation monitors |

[Original reference PR
#15962](https://github.com/chatwoot/chatwoot/pull/15962), frozen at
`562de2bccef4ff9b13179a35e08cb09971971c4c`.

Review and merge in this order. Only the first PR targets develop.
Before merging a child, replay its own changes onto updated develop and
retarget it; do not merge it into the predecessor branch. Account for
squash-merge ancestry and refresh descendants in order. Keep collection
disabled until the complete stack is merged and verified.

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

## How to test

Apply the single migration on an isolated installation and confirm the
five monitor tables are created. Roll it back and reapply it. Confirm
the monitor flag remains disabled and no new dashboard entry or message
processing is exposed. Existing local prototype databases should be
reconciled separately; this migration describes the unreleased
production schema.

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

Regression tests are carried with the implementation slice they
exercise. This is an extraction of the validated reference feature;
predecessor drafts remain unmerged. Shared OpenRouter configuration must
be reconciled with [classifier PR
#15906](https://github.com/chatwoot/chatwoot/pull/15906) before both
features land.

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-09-24 17:27:47 -07:00
Vishnu Narayanan
367bfb35ca feat: account setting to enforce two-factor authentication (#15877)
# Pull Request Template

## Description

Adds an account-level setting, "Enforce two-factor authentication", that
requires every member of the account to enrol in TOTP.

- New `enforce_mfa` key in account `settings`, editable by
administrators from Account Settings. Available on all plans, and hidden
when the installation has no Active Record encryption keys configured.
- A member without 2FA signing in with a password receives a `206` setup
challenge and completes enrolment on the login page itself (QR or manual
secret, verification, backup codes), then lands in the app
authenticated. Nobody is ever locked out: the setup path is always
completable.
- Sign-ins through Google or SAML are exempt, since the identity
provider already applies a second factor. SAML members are also exempt
from the token gate below.
- `api_access_token` requests from members who have not enrolled are
rejected with `403` / `error_code: mfa_enrollment_required` for the
enforcing account. Session-minting side doors are covered as well:
account update, v1/v2 account creation, and conversation direct uploads.
Profile MFA endpoints now require an interactive session, so an access
token cannot enrol or manage 2FA by itself.
- Password reset and email confirmation no longer issue a session when
the user still owes a second factor (enrolled or pending); they are
redirected to sign in.
- While enforcement is on, members cannot disable their own 2FA
(server-side `403`, and the profile UI shows a "required by your
account" note). Backup code regeneration remains available.
- The setup challenge token is purpose-tagged and bound to the current
password, the login MFA token now carries a typed claim, and new
Rack::Attack buckets rate limit setup verification. As part of this, the
auth throttles now parse JSON bodies; previously the `mfa_login/*`
buckets never matched the web client's JSON sign-in requests.

Note for mobile clients: a password sign-in into an enforcing account
returns `206` with `mfa_setup_required` and a readable `error` message.
Un-enrolled users are intentionally hard-blocked on mobile until they
enrol via the web app.

Ref: https://linear.app/chatwoot/issue/CW-8206

## Type of change

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

## How Has This Been Tested?

- Request/controller/model specs across sign-in, activation, token
gates, reset/confirmation, and profile MFA endpoints, all guarded by the
existing encryption-config skip and added to `run_mfa_spec.yml`.
- Component tests for the login enrolment wizard.
- Manual browser run on a local build: enable toggle as admin, forced
enrolment with wrong and right codes, backup codes, subsequent sign-in
challenge, API token gating, and blocked self-disable.

## 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 my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
2026-09-24 17:06:06 -07:00
Aakash Bakhle
650e448d97 feat: sunset Captain V1 (#15985)
## Summary

- Remove the Captain V1 conversation runner, model services, settings,
and playground paths. Route Captain assistants through V2.
- Keep `captain_integration` at its original bit as the active paid
Captain entitlement. Retain `captain_v1_action_classifier` and
`captain_integration_v2` in their original positions as deprecated
markers so persisted bits retain their meaning.
- Preserve legacy assistant instructions only as source data for the
existing reviewed migration task. Add a self-hosted migration runbook.
- Keep editor and Copilot routes and model selection working with the
shared Captain entitlement.

## Release checklist

- [x] **Cloud paid Captain inventory:** The production Rails console
query returned `paid_captain_accounts: 393` and `paid_without_v2_ids:
[]` for accounts with a linked assistant. All 393 paid Captain accounts
in that group have V2 enabled. The operator also confirmed no Captain V1
traces for the past 2 to 3 weeks. Linked assistants on accounts without
the paid Captain feature are outside this rollout check. Repeat the paid
entitlement query immediately before rollout.
- [x] **Stored data compatibility:** `captain_integration` remains the
active paid entitlement at its original bit. The retired
`captain_v1_action_classifier` and `captain_integration_v2` entries
retain their bit positions as deprecated entries. Legacy assistant
instructions remain stored for migration. This PR adds no database
migration.
- [x] **V2 runtime and UI:** In isolated Cook slot 2, a synthetic
customer message received a Captain reply with V2 response parts. A V2
system setting saved through the UI and was verified in the database,
then restored. The playground replied on desktop and mobile.
- [x] **Editor and Copilot:** The editor generated a reply suggestion
that was discarded without sending. Copilot summarized the synthetic
conversation, and the user, tool, and assistant messages persisted. Ruby
examples for these paths passed.
- [x] **Migration task smoke test:** A synthetic legacy assistant passed
`captain:assistant_migration:generate`, dry-run `apply` without writes,
and reviewed `apply` with V2 guidelines and guardrails saved. Original
instructions were preserved.
- [ ] **Self-hosted coverage:** Publish the upgrade notice and runbook.
Admins using V1 instructions must migrate them before installing the V1
removal release. The task is available from v4.16.0, which still has V1.
It handles only assistants with nonempty V1 instructions, a linked
inbox, and no existing scenarios, guardrails, or guidelines. Its
generator requires an endpoint that serves `gpt-5.2`; provide a manual
path for other installations. Admins need a backup and should test
migrated assistants before the final upgrade.
- [ ] **Self-hosted rehearsal:** Upgrade a representative paid
Enterprise installation with V1 instructions, and separately rehearse
the manual path for a provider that cannot serve `gpt-5.2`. Verify V2
settings, inbox replies, and model selection before publishing the
release.
- [x] **Queued work snapshot:** A production Rails console check at
2026-09-23 22:59 UTC returned `{}` for Captain `ResponseBuilderJob`
across queued, scheduled, retry, dead, and running Sidekiq jobs. The
operator reports no V1 jobs during the past month; this snapshot only
verifies current Redis state.
- [ ] **Cutover timing and pending conversations:** Confirm the
end-of-October sunset window and verify pending conversations transition
safely.
- [x] **Final CI:** All PR checks passed on head `fa6881f9e8`, including
Ruby lint, frontend tests, backend tests, builds, and CircleCI coverage.
Local frontend Vitest could not collect tests through the linked
`fake-indexeddb/auto` dependency.
- [ ] **Canary and rollback:** Watch Captain response failures,
handoffs, response credits, editor suggestions, and Copilot after
deployment. Keep the previous release and database backup available for
rollback.

## Self-hosted migration

V2 becomes the Captain runtime after upgrade. Admins who rely on V1
instructions must move them into V2 settings before installing the V1
removal release. They can use the existing `captain:assistant_migration`
task on a release that still has V1, or configure V2 manually. The task
is optional, handles only eligible assistants, and requires an endpoint
that serves `gpt-5.2`; its drafts need human review. The task is present
from v4.16.0. See `docs/captain_v1_sunset.md` for the upgrade order and
limits.

## Verification

- 520 changed-path Ruby examples passed; 78 final focused examples
passed after the last runtime edits.
- 48 Copilot examples and 11 editor reply suggestion examples passed.
- ESLint passed for changed frontend files. The six Ruby files flagged
by CI now pass RuboCop locally.
- Captain V2 settings, a live inbox reply, and playground replies were
checked in the in-app browser at desktop and mobile sizes.
- The migration generator and apply task were exercised with synthetic
data in an isolated Cook environment.
- Frontend Vitest suites could not collect tests in this worktree
because the linked `fake-indexeddb/auto` dependency failed to load.
2026-09-24 16:28:32 -07:00
Sojan Jose
cdfaa255dc feat(companies): make core CRM available to Startups and self-hosted (#15963)
Basic Companies is now available by default for new Community and
self-hosted accounts, and included in Chatwoot Cloud Startups and
higher. AI enrichment remains in Enterprise. Existing-account enablement
will be handled manually, outside this PR.

## Why

Company records and contact association are core CRM capabilities, but
the previous premium flag and dashboard installation gate kept the core
implementation unavailable to Community users.

## What this change does

- Moves the existing Companies model, APIs, jobs, services, views, and
tests from the Enterprise overlay into core without changing the API
contract.
- Makes `companies` a standard default feature for new self-hosted
accounts.
- Includes Companies in the Cloud Startups entitlement (and higher
plans), while new Hacker accounts remain gated until billing assigns a
plan.
- Removes the dashboard's Cloud/Enterprise installation restriction. The
account feature flag and API authorization still apply.
- Does not backfill existing account flags or persisted installation
feature defaults; those will be updated manually during rollout.

## Validation

- In an isolated Community preview (`DISABLE_ENTERPRISE=true`), opened
the Companies list and detail page, edited a company, and reassigned a
contact through the company selector.
- In an isolated Enterprise-enabled preview, created a company through
the contact selector's Add company modal and saved the contact
association.
- Checked a new Cloud account through Hacker, Startups, and Hacker
entitlement states; Companies was off, on, and off respectively.
- Checked that disabling the account feature hides Companies navigation
and the API rejects the list request.
2026-09-23 15:40:46 -07:00
Sivin Varghese
0466fe91e5 feat: add reading mode for older conversations (#15938)
# Pull Request Template

## Description

This PR adds a reading mode when browsing a customer's older
conversations, so agents don't accidentally reply from an old
conversation.
Clicking **Previous conversation** switches the list to the customer's
history and opens older conversations with the editor folded into
**Reply to this older conversation**. Clicking the bar or using a
writing action opens the editor, while the latest conversation opens
with the normal editor.

### What changed

Previous/Next pills now navigate into the customer's history view. Older
conversations use a reading mode with the editor folded by default,
while the latest conversation keeps the normal editor.
The editor is restored when clicking the bar or when a writing action is
triggered. The back arrow returns to the latest conversation.
Also shortened the pill labels, moved the customer name to the tooltip,
limited previews to the first paragraph, and shared one
`useContactConversationNavigation` instance per conversation view.

Fixes
https://linear.app/chatwoot/issue/CW-8265/replies-sent-while-browsing-an-older-conversation-land-in-that-older

## Type of change

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

## How Has This Been Tested?

### Screenshots
<img width="1668" height="972" alt="image"
src="https://github.com/user-attachments/assets/fb579f90-419c-4760-97de-aae66c150521"
/>




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-23 15:09:13 -07:00
Tanmay Deep Sharma
af1969a731 fix(voice): use Twilio API key credentials for recordings and outbound calls (#15958)
Call recordings and transcripts now work for Twilio voice inboxes set up
with an API Key. Before this change, calls went through fine but the
recording never showed up on the call and no transcript was created,
because Chatwoot downloaded the recording with the wrong credential
pair. Agent-initiated outbound calls had the same problem and now use
the same credentials as the rest of the voice channel.

## How to reproduce

1. Create a Twilio inbox with voice enabled, using an API Key SID and
API Key Secret.
2. Enable call recording and place a call, then end it.
3. The call works, but no recording is attached and no transcript
appears. Logs show Twilio rejecting the recording download with a 401
(error 20003).

## What changed

- Added `basic_auth_credentials` on `Channel::TwilioSms`. OSS pairs
`api_key_sid` (or `account_sid`) with `auth_token`; the enterprise
override prefers `[api_key_sid, api_key_secret]` when both are set,
mirroring the existing `client` override.
- The recording download and the Twilio media download now use
`basic_auth_credentials`.
- The outbound voice adapter now uses `channel.client` instead of
building its own client from `account_sid` and `auth_token`.
2026-09-23 12:44:49 -07:00
Tanmay Deep Sharma
778fc6fa37 fix: escape mention labels and sanitize the article diff panel (#15965)
Mention labels (`[@name](mention://…)`) are now HTML-escaped when
markdown is rendered, so they're handled the same way as the rest of the
message text. The Help Center **Pending changes** panel also renders the
title and content diff through the sanitized `v-dompurify-html`
directive used across the dashboard.

## Closes

- Closes CW-8212 — https://linear.app/chatwoot/issue/CW-8212

## How to test

1. In a conversation, add a private note that mentions an agent or a
team — the mention pill renders as before.
2. Open a Help Center article that has unpublished edits (ideally one
with a table and a video link) and click **Pending changes** — the title
and content diffs render as before; tables keep their column widths and
video links still show a player.
3. A mention whose label contains `<`, `>` or `&` now shows those
characters literally.

## What changed

- `shared/helpers/markdownIt/link.js`: the mention renderer escapes the
label with `md.utils.escapeHtml`.
- `ArticleDiffPanel.vue`: `v-html` → `v-dompurify-html` for both the
title and the content blocks.
- `MessageFormatter.spec.js`: covers mention rendering and label
escaping.
2026-09-23 12:40:07 -07:00
Sojan Jose
e87cea84d2 docs(api): document bearer token authentication (#15942)
Document `Authorization: Bearer <token>` for existing Chatwoot API
tokens, available from v4.19.0. The OpenAPI auth option explains where
to obtain user, agent bot, and platform app tokens while retaining
`api_access_token` as a legacy option.

Follow-up to https://github.com/chatwoot/chatwoot/pull/15941. Related:
https://github.com/chatwoot/chatwoot/issues/1615 and
https://linear.app/chatwoot/issue/CW-2866/chore-standardise-the-access-token-based-authentication-in-chatwoot.

## Why

API consumers and generated clients should be able to discover the
standard Bearer header and reuse their existing tokens without changing
permissions.

## What this change does

- Adds HTTP Bearer as a separate authentication alternative wherever
existing API tokens are accepted.
- Explains that Bearer authentication is available from Chatwoot v4.19.0
and where to get user, agent bot, and platform app tokens.
- Recommends Bearer auth while keeping the `api_access_token` header
supported ahead of a future deprecation.
- Updates the attachment request example and generated OpenAPI JSON.

## Validation

Open the generated API documentation and confirm Bearer and legacy API
key options appear. On a v4.19.0 instance, authorize with an existing
API token and call an allowed endpoint. Confirm the attachment example
uses `Authorization: Bearer`.
2026-09-23 10:22:59 -07:00
emilienlefort
359c4d4ce7 fix: only apply HUMAN_AGENT tag to human replies outside the 24-hour window (#15932)
## Description

When `ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT` or
`ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT` is enabled, every outgoing Meta
message is currently sent with `messaging_type: MESSAGE_TAG` and `tag:
HUMAN_AGENT` — regardless of who wrote it and whether the conversation
is still inside the standard 24-hour window.

Meta only allows the HUMAN_AGENT tag on replies written by a human agent
and sent after the 24-hour window has closed. Tagging automated replies
(agent bots, Captain) violates the Messenger Platform policy, and
tagging ordinary in-window replies is incorrect usage. Apps that enable
the flag today are exposed to enforcement on the whole app.

This PR applies the tag only when both conditions hold:

- the sender is a human agent (`User`), and
- the last incoming message in the conversation is older than 24 hours.

Otherwise the message is sent exactly as it is today when the flag is
off (`RESPONSE` for Facebook; no tag for Instagram).

The check lives in a small shared module,
`Facebook::HumanAgentTagHelpers`, included by
`Facebook::SendOnFacebookService` and `Instagram::BaseSendService`, so
the three send paths — Messenger, Instagram Login and Instagram via a
Facebook Page — share one rule. It reuses
`Conversations::MessageWindowService::MESSAGING_WINDOW_24_HOURS` for the
window length.

Automation rule messages have no sender, so they are never tagged.
Macros run by an agent keep the agent as sender, so they are tagged
outside the window, as a human-initiated reply should be.

Fixes #15887
Related: #14679

## Type of change

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

## How Has This Been Tested?

The existing HUMAN_AGENT specs were rewritten, because they encoded the
behaviour being fixed or did not assert the payload at all:

- `send_on_facebook_service_spec` asserted that any message is tagged
when the flag is on.
- `instagram/send_on_instagram_service_spec` enabled
`ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT` while the service reads
`ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT`, and only checked that
`HTTParty.post` was called — so it passed whatever tag was sent.
- `instagram/messenger/send_on_instagram_service_spec` had the same
`have_received(:post)` check without arguments.

Each of the three now covers:

- a human agent reply after the 24-hour window → tagged `HUMAN_AGENT`
- a human agent reply inside the window → not tagged
- an agent bot reply after the window → not tagged

Tests use `AgentBot` rather than `Captain::Assistant`, since the CE
suite runs with `enterprise/` removed. Captain senders are covered by
the same `User` check.

Run locally in CE mode, mirroring `run_foss_spec.yml` (`enterprise/` and
`spec/enterprise/` removed, Ruby 3.4.4, `pgvector/pgvector:pg16`,
Redis):

```
bundle exec rspec spec/services/facebook/send_on_facebook_service_spec.rb \
                  spec/services/instagram/send_on_instagram_service_spec.rb \
                  spec/services/instagram/messenger/send_on_instagram_service_spec.rb
```

- With this change: **37 examples, 0 failures**.
- Same specs against unmodified `develop` services: **37 examples, 6
failures** — exactly the six negative cases (in-window human reply, and
agent bot reply after the window, for each of the three services). The
new specs fail on the old behaviour and pass on the fix.
- `rubocop` on the eight changed files: no offenses.

## Checklist:

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-09-23 12:01:38 +04:00
Sivin Varghese
70c69e5781 feat: add label and priority suggestions (#15964) 2026-09-23 05:56:44 +05:30
Shivam Mishra
fe6e56372b fix: restore timezone-aware date filters without scan regression (#15843)
Date filters once again follow the user's local calendar day, without
the performance regression that led to the rollback in #15815. A
conversation created on September 7 at 9:49 PM in Brasília (September 8
at 00:49 UTC) now matches a `Created at < September 8` filter, the same
way the day's report counts it.

Existing folders and contact segments keep their previous UTC behavior
until they are opened and saved once, which records the browser's
timezone on their date conditions.

### Closes

- Reintroduces #15784 after the rollback in #15815
- Support report:
https://app.chatwoot.com/app/accounts/1/conversations/91709

### How to reproduce

1. Use a browser in `America/Sao_Paulo` and a conversation created at
`2026-09-08 00:49 UTC`, which appears as September 7 at 9:49 PM locally.
2. Apply `Status = All`, `Created at > September 6`, and `Created at <
September 8`, then save the folder.
3. Before this fix, the conversation is missing even though it appears
in the September 7 report. With this fix, it appears in the folder and
stays there after reloading.
4. Open an older folder with a date condition, save it once, and confirm
it now uses local calendar days.

### What changed

- Local dates are converted to UTC boundaries before querying, so the
timestamp column is compared directly and PostgreSQL can use its
indexes. On 500,000 indexed timestamps, a selective filter went from a
150.57 ms sequential scan to a 0.95 ms index-only scan.
- `Days before` uses today's date in the saved timezone.
- Conversation lists compute the boundaries once per filter set instead
of formatting every conversation.
- Invalid timezones return `422 Unprocessable Entity`.
- Automation rules and Captain audience conditions keep their existing
behavior.

### Why not send UTC timestamps instead

Converting the local date to a UTC timestamp in the frontend fixes
`is_less_than` and `is_greater_than`, but `Days before` needs today's
date in the user's timezone at query time, so it would stay on UTC.
Saved folders would also store timestamps that must be converted back to
dates for editing. Saving the timezone fixes all three operators and
keeps the stored value as the date the user picked.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-09-22 16:55:33 -07:00
Sojan Jose
8aa1fafe20 feat(api): support bearer authentication for existing API tokens (#15941)
Existing Chatwoot API tokens can now be sent as `Authorization: Bearer
<token>` on Application APIs, conversation direct uploads, and Platform
APIs. The legacy `api_access_token` header remains supported, with the
same token permissions and lifetime.

## Closes

Closes https://github.com/chatwoot/chatwoot/issues/1615
Closes
https://linear.app/chatwoot/issue/CW-2866/chore-standardise-the-access-token-based-authentication-in-chatwoot

## Why

A standard Authorization header works with common API clients and
proxies that drop headers containing underscores. This provides the
token transport needed for future OAuth support without adding OAuth
issuance or changing authorization in this PR.

## What changed

- Accept existing user, agent bot, and platform app tokens through
Bearer authentication.
- Preserve encoded dashboard Bearer authentication and existing account,
bot, and platform resource restrictions.
- Give Bearer tokens precedence when both API headers are present;
invalid Bearer tokens return 401.

## How to test

1. Call `/api/v1/profile` using an existing user token in
`Authorization: Bearer <token>`; verify the same profile is returned
using `api_access_token`.
2. Send different tokens in both headers and verify the Bearer token
determines the authenticated identity. An invalid Bearer token returns
401 even when a valid legacy token is present.
3. Sign in to the dashboard and verify normal dashboard requests still
work.
4. Use an agent bot token on an allowed conversation endpoint and for a
conversation direct upload. Verify disallowed endpoints remain denied.
5. Use a platform app token on a permitted Platform API resource and
verify an unpermitted resource remains denied.
2026-09-22 15:04:15 -07:00
Muhsin Keloth
e3b05e4d76 fix(contacts): restore history ordering by recent activity (#15950)
Contact history can omit an older conversation even when it has recent
activity. Ordering the limited history list by creation date puts newer,
less recently active conversations ahead of it, affecting both the
contact history UI and the contact conversations API.

This restores ordering by last activity, with ID as a stable
tie-breaker, so recently active conversations appear in the history
again.

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

### Things to know

The list remains limited to 25 conversations. Permission filtering and
creation-date-based in-thread navigation are unchanged.

### How to reproduce

1. Use a contact with more than 25 conversations, including an older
WhatsApp conversation outside the 25 newest by creation date.
2. Receive or send a message in that older conversation.
3. Open the contact's history. Before this fix, the conversation is
omitted despite its recent activity.

### How to test

Repeat the steps above and confirm the older conversation appears near
the top of the history. Confirm the contact conversations API returns it
and that previous/next conversation navigation still follows creation
order.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-22 12:27:53 +04:00
Vishnu Narayanan
3e31538510 fix: exclude credential attributes from channel audit log entries (#15851)
## Description

Channel updates create an audit log entry against the inbox with the
channel's `saved_changes`. This change extends the excluded attribute
list so that `provider_config`, known credential columns, and any Active
Record encrypted attributes of the channel (for example email IMAP/SMTP
passwords, bot tokens, and access tokens) are never written to the
`audits` table. The static list covers installs where Active Record
encryption keys are not configured; the dynamic `encrypted_attributes`
lookup covers future encrypted columns. Legitimate configuration changes
continue to be audited as before.

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

## Type of change

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

## How Has This Been Tested?

Added specs in `spec/enterprise/models/inbox_spec.rb` covering email and
telegram channels: updating `provider_config` and credential attributes
does not add those keys to the audit entry, while a regular attribute
change in the same update is still recorded. Ran `bundle exec rspec
spec/enterprise/models/inbox_spec.rb` both with and without
`ACTIVE_RECORD_ENCRYPTION_*` keys configured; all examples pass in both
modes.

## 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
2026-09-21 23:05:41 -07:00
Vishnu Narayanan
7b3c928bf1 fix: rate-limit auth throttles read JSON request bodies (#15939)
## Description

The authentication rate-limit rules read request parameters with Rack's
`req.params`, which only parses query strings and form bodies. The
dashboard and mobile clients post JSON, so for those requests every key
came back empty. Two consequences:

- The MFA/device-verification code submissions were not recognised as
such (the `mfa_token` was invisible), so they were counted against the
password-login throttles and the dedicated `mfa_login/*` throttles never
fired.
- Code submissions carry no email, so the `login/email` discriminator
resolved to an empty string. Because an empty string is a valid key,
every code submission across the whole deployment shared a single
bucket, effectively one global limit for all users.

This change adds a JSON-aware `auth_param` helper on the throttle
request class that falls back to `ActionDispatch::Request` (which parses
JSON) and returns nil for blank or non-string values, so a throttle
keyed on a missing value skips instead of sharing one bucket. It is
applied to the login, password-reset and resend-confirmation email
throttles and to the MFA-login throttles. Parameter parsing is wrapped
so a malformed body or an oversized/over-nested query can never raise
out of a throttle. The blocked-event log line now records which rule
matched.

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

## Type of change

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

## How Has This Been Tested?

Added `spec/initializers/rack_attack_spec.rb` covering the login,
MFA-login, reset-password and resend-confirmation throttles for both
JSON and form-encoded bodies: MFA submissions are exempted from the
password throttles, a missing email skips instead of sharing a blank
key, a non-string email and a malformed/over-nested body fail closed
without raising, and the blocked-event log records the matched rule.
Existing sessions and device-verification specs pass.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-21 23:03:08 -07:00
breken
5cb3650a42 docs: document Sidekiq health check configuration (#15820)
Document `SIDEKIQ_ALIVE_HOST` and `SIDEKIQ_ALIVE_PORT` in `.env.example`
so operators can configure the Sidekiq health endpoint. Uncommenting
`SIDEKIQ_ALIVE_HOST=127.0.0.1` restricts the listener to localhost;
deployments using remote probes can configure the appropriate bind
address.

The gem reads both variables directly, so no application initializer is
needed. Without an explicit host setting, its existing `0.0.0.0:7433`
default remains unchanged. Existing installations must set the variable
and restart their workers to restrict the listener. This documentation
change does not fix the gem's EOF parsing bug.

Fixes #15818.

Thanks to @md-riaz for reporting and investigating the issue.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-09-22 08:20:46 +05:30
Vishnu Narayanan
0acc3be540 fix: point to the emailed code in the device verification error (#15937)
## Description

Device verification returns its errors as server-rendered strings, and
both the web login screen and the mobile app display them verbatim
(inline on web, toast plus inline text on mobile). When a wrong code is
submitted, the current message, "Invalid verification code", does not
tell the user where the correct code actually is.

This matters most on mobile builds that predate [feat(auth): verify new
devices with an emailed code on sign-in
(chatwoot-mobile-app#1183)](https://github.com/chatwoot/chatwoot-mobile-app/pull/1183).
Those builds reuse the MFA screen for the device verification challenge,
so the on-screen copy points at an authenticator app. Device
verification only triggers for users without MFA enabled, so those users
have no authenticator code, and the emailed code is the only valid
input. Since the error string comes from the server, updating it here
reaches already-shipped app versions without an app update.

The message now reads: "Incorrect code. Enter the 6-digit code we sent
to your email."

## Type of change

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

## How Has This Been Tested?

Validated the locale file parses and the key resolves through I18n. No
specs assert this string literally, the controller specs compare against
`I18n.t` and the service specs use error symbols, so behavior coverage
is unchanged.

## 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
2026-09-21 17:56:01 -07:00
Sivin Varghese
ef2c00a67e feat: support tables in email replies (#15830)
# Pull Request Template

## Description

This PR adds table support to the email reply editor. Agents can insert
tables from the editor menu, edit them in place, and send them as real
HTML tables with inline styles that render reliably in Gmail and
Outlook, in both light and dark mode.

It also fixes an issue where the reply editor stopped growing beyond
120px after visiting a Help Center article. `FullEditor.vue`'s global
editor height cap was leaking into the reply editor, causing the message
body to remain clipped when expanding or resizing the editor.

### https://github.com/chatwoot/prosemirror-schema/pull/60

### What changed

* `Channel::Email` formatting now enables the `table` node and
`insertTable` menu item. Column resizing is disabled for messages.
* `ChatwootMarkdownRenderer` and `MarkdownRendererService` now enable
CommonMarker's table extension.
* `BaseMarkdownRenderer` renders tables with inline styles and ensures
empty cells retain their height.
* `MessageFormatter` fills empty markdown table cells so they render
correctly in conversation bubbles and the quoted-reply preview.
* `FullEditor.vue` no longer applies a global editor height constraint;
the article editor already defines its own height.
* Requires `@chatwoot/prosemirror-schema` with message table support.

Fixes
https://linear.app/chatwoot/issue/CW-8157/support-tables-in-email-bodies

## Type of change

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

## How Has This Been Tested?

### How to test

1. Open an email conversation, select some text in the reply, and click
the table icon in the editor menu.
2. Enter content in the cells, add rows and columns using the hover
controls, and send the message.
3. Verify that both the conversation bubble and received email render a
bordered table. Also verify that an empty cell retains its height.
4. Open a Help Center article, return to the conversation, and expand
the reply box. Verify that the editor grows to fill the available space.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-21 15:18:00 -07:00
Vishnu Narayanan
eec3e6d974 fix: stop clearing the config cache on every read of falsy global configs (#15900)
## Description

`GlobalConfigService.load` returned early only when the cached value was
`present?`. A stored
boolean `false` (any `type: boolean` config that is switched off) is not
"present", so every
call fell through to the one-time ENV migration path: an
`InstallationConfig.first_or_create`
plus an unconditional `GlobalConfig.clear_cache`. On a hot caller this
means a DB roundtrip and
a full config cache wipe per call, and `clear_cache` itself ran a
blocking Redis `KEYS` command
followed by one `EXPIRE` per key.

Three changes:

- `load` returns cached nonblank values and boolean `false`. Stored
blank strings are still
treated as unconfigured, preserving current behavior for installations
that saved empty
  values (for example Firebase settings) from super admin.
- After a cached blank, the cache is cleared when the database lookup
returns a configured
value, including boolean `false`. This also handles a row created by a
concurrent request;
  existing blank rows do not trigger a cache clear.
- `clear_cache` uses cursor-based `SCAN` followed by one multi-key `DEL`
instead of `KEYS`,
  avoiding a single blocking keyspace scan.

The hot caller that surfaced this is fixed independently in
https://github.com/chatwoot/chatwoot/pull/15899.

## Type of change

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

## How Has This Been Tested?

New specs cover: a stored boolean false served from cache without
clearing other cached
configs, an existing row with a blank value not clearing the cache, a
stored empty string
still treated as unconfigured, and the create path clearing a stale
cached nil. Existing
`GlobalConfig` cache specs pass unchanged. A deterministic two-reader
probe also confirms
that concurrent configuration creation clears the stale cached nil and
subsequent reads
are served from cache.

## Checklist:

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

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-09-21 15:10:38 -07:00
Sivin Varghese
b687420abb feat: improve quoted content experience in email replies (#15746) 2026-09-22 03:00:27 +05:30
Shivam Mishra
9ae63601ce feat(captain): scope custom tools to assistants (#15701)
Custom tools now belong to individual Captain assistants, allowing each
assistant to use its own integrations with a limit of 50 tools. Existing
account-level tools are copied to every current assistant, and a
dismissible announcement explains the change through Oct 31, 2026.

We've also added notices to notify the users of the change

<img width="2460" height="1646" alt="CleanShot 2026-09-10 at 14 40
38@2x"
src="https://github.com/user-attachments/assets/2863b30b-2016-4ede-b5f9-9f6ca544aa8b"
/>

<img width="2460" height="1646" alt="CleanShot 2026-09-10 at 14 40
35@2x"
src="https://github.com/user-attachments/assets/2fc355a5-7670-457a-bfb6-1fc32bde4321"
/>


## Closes

-
https://linear.app/chatwoot/issue/CW-8087/scope-custom-tools-to-each-captain-assistant
- https://linear.app/chatwoot/issue/CW-8088/increase-the-tool-limit

## How to test

1. Open the Tools page for a Captain assistant and confirm the migration
announcement appears.
2. Dismiss the announcement and reload the page to confirm it stays
dismissed.
3. Create or edit a tool, switch assistants, and confirm each assistant
has an independent copy.
4. Confirm an assistant can have up to 50 custom tools.
2026-09-21 15:02:39 +05:30
Shivam Mishra
5f37921810 feat: add drilldowns to Captain overview [CW-8131] (#15725)
Adds admin-only episode drilldowns to the Captain overview so aggregate
metrics and resolution flows can be traced back to their underlying
conversations.

- Add drilldowns for handled, auto-resolved, handed-off, reopened, and
durable-resolution metrics.
- Add drilldowns for resolution-flow nodes and individual handoff
reasons, excluding the aggregated Other reasons node.
- Keep repeat conversation episodes distinct and show their start times
in the drawer.
- Reuse the report drilldown cards with pagination, loading, empty, and
error states.
2026-09-21 14:36:47 +05:30
Shivam Mishra
87b0808bef fix: trigger dashboard alerts on bot handoff (#15763)
Bot handoffs now play the dashboard sound and show a browser-tab badge,
so agents know when a conversation needs human attention without waiting
for another message.

Alerts use the agent's existing audio settings, including assignment
filters and whether to alert while the dashboard is visible. Captain
private notes remain visible in the conversation but no longer trigger
alerts. The handoff itself is what requests attention.

## How it works

- A bot handoff starts a 2.5-second window, giving automatic assignment
time to finish.
- At the end of the window, the dashboard checks the latest available
assignment and status before applying the agent's alert settings.
- A human takeover or a conversation that is no longer open cancels the
alert. Unrelated updates, such as an out-of-office message, do not
cancel it.
- If the conversation is not stored locally, the dashboard uses the
original handoff details.
- A bot handoff now only applies to pending conversations. On a
conversation that is already open or resolved, it does nothing: no
status change, no `waiting_since` update, and no handoff event. This
affects agent-bot and API handoffs, handoff notifications, and handoff
reporting, not just alerts.

```mermaid
sequenceDiagram
    participant Captain
    participant Conv as Conversation#bot_handoff!
    participant Listener as ActionCableListener
    participant Assign as Auto-assignment
    participant WS as actionCable.js
    participant Audio as DashboardAudioNotificationHelper

    Captain->>Conv: hand off
    Conv->>Conv: return false unless pending?
    Conv->>Listener: conversation.bot_handoff
    Listener->>WS: broadcast to inbox members
    WS->>WS: start 2.5s timer (key = account:id, duplicates ignored)
    Assign->>Listener: assignee_changed (performed_by Inbox/AssignmentPolicy)
    Listener->>WS: assignee.changed + assignment{automatic, assignee_id, type, updated_at}
    WS->>WS: store updates conversation
    Note over WS: timer fires
    WS->>WS: alertOnSettledHandoff()
    WS->>Audio: onConversationBotHandoff(conversation)
    Audio->>Audio: triggerAlert → filters → sound + badge + 30s repeat
```

The window is intentionally limited. If automatic assignment takes
longer than 2.5 seconds, agents using assignment-based filters may miss
that handoff alert. A late assignment does not restart it.

## Alert preferences

Agents control these alerts in **Profile Settings → Audio Alerts → Alert
events for conversations**, using the same options as message audio
alerts: my assigned conversations, unassigned conversations, and open
conversations assigned to others.

Unchecking all three disables handoff sounds and their tab badges
(`ui_settings.enable_audio_alerts = 'none'`). There is no separate
bot-handoff toggle, so this also disables message audio alerts. The
existing inactive-window and 30-second reminder settings apply to
handoffs too.

## How to test

1. Enable dashboard audio alerts and switch to another browser tab.
2. Ask Captain to hand a pending conversation to a human. Confirm the
sound and tab badge appear after about 2.5 seconds, without a second
alert from the private note.
3. Try assignment filters, disabled alerts, and viewing the conversation
in the active tab. Confirm the existing settings are respected.
4. With automatic assignment enabled, select "My assigned conversations"
and confirm the selected agent is alerted when assignment finishes
within the window.
5. Take over or resolve the conversation during the window and confirm
the alert is cancelled. An out-of-office message alone should not cancel
it.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-09-21 14:00:55 +05:30
Vishnu Narayanan
f7f7754511 fix(shopify): read the feature gate without config write side effects (#15899)
## Description

`Shopify::FeatureGate.globally_enabled?` resolved the flag through
`GlobalConfigService.load`,
which is a write-capable helper. When the stored value is boolean
`false` (the shipped default
for `ENABLE_SHOPIFY_INTEGRATION`), it never takes the cached early
return, so every call runs
`InstallationConfig.first_or_create` and `GlobalConfig.clear_cache`.
Since the gate is invoked
from the account payload and integration surfaces added in
https://github.com/chatwoot/chatwoot/pull/15756, every dashboard request
paid for a DB
roundtrip, a full config cache wipe, and the blocking Redis `KEYS` scan
inside `clear_cache`.

The gate now checks `ENV` first and falls back to the cached,
side-effect-free
`GlobalConfig.get`. Flag behavior is unchanged: disabled by default,
enabled via super admin.
The ENV override now genuinely works as a kill switch; previously it was
unreachable once a
config row existed, even though the specs asserted it.

Specs that enabled the gate by stubbing `GlobalConfigService.load` now
configure a real
installation config row instead, since the stubbed call no longer
happens.

## Type of change

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

## How Has This Been Tested?

Feature gate spec rewritten against real config rows, covering the
disabled/enabled states,
cache survival across calls, no config writes from the read path, and
both ENV overrides.
All OSS and Enterprise specs referencing the flag were run locally and
pass.

## 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
2026-09-19 01:19:47 -07:00
Vishnu Narayanan
e424b13f59 chore: expose device verification toggle in super admin settings (#15897)
## Description

Follow-up to #15879. Adds `DEVICE_VERIFICATION_ENABLED` to the internal
super admin config allowlist so the device verification toggle can be
flipped from Super Admin → Settings → Internal instead of the Rails
console.

## Type of change

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

## How Has This Been Tested?

Added a request spec asserting the toggle saves through the internal
config section. Existing app config specs pass.

## 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
2026-09-18 18:25:14 -07:00
shaurya
4a72230d59 docs(swagger): document team icon and icon_color fields (#15882)
The teams API already accepts `icon` and `icon_color` when you create or
update a team, and returns both on the team resource, but the API
reference never documented them. Anyone reading the docs wouldn't know
they can set or read a team's icon. This adds the two fields so the spec
matches what the endpoint actually does.

The fields were introduced with team icons (#14767); the swagger
definitions were missed at the time.

### What changed
- Added `icon` and `icon_color` (both `string`) to the team
create/update request payload and to the team resource in
`swagger/definitions/`.
- Regenerated `swagger.json` from the definitions.

Co-authored-by: Shaurya <19599684+no-hup@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-09-18 17:15:27 -07:00
Vaibhav Mashal
b96b4b3b8e fix(dashboard): resolve type mismatch in conversation filter matching (#15767) (#15768)
Fix custom-attribute conversation filtering by using numeric inputs for
number attributes and preserving zero and false values when matching
conversations.

## Related issue

Related to #15767. The original inbox-ID mismatch was fixed separately
in #14523; this PR addresses the remaining custom-attribute cases.

## Why

Number filters previously emitted text values, which did not match
numeric conversation values in the dashboard. Zero and false were also
treated as missing custom attributes.

## What changed

- Reuse the existing numeric input conversion for number custom
attributes.
- Preserve zero and false when reading conversation custom attributes.
- Keep strict equality unchanged instead of coercing every attribute to
a string.

## How to reproduce

1. Set a number custom attribute on a conversation, then filter for that
number.
2. Repeat with zero and a checkbox attribute set to false.
3. Check that matching conversations remain visible and that the
not-equal filter excludes them.

Existing saved filters containing string values are not migrated by this
change. Fresh UI verification is still pending.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-09-18 16:34:59 -07:00
Sivin Varghese
a6a6f348eb feat: show banner in Super Admin (#15853)
# Pull Request Template

## Description

This PR adds a banner in Super Admin when a user session is active.


## Type of change

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


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-19 04:53:01 +05:30
Vishnu Narayanan
910f5e2f49 feat(security): device verification for cloud password sign-ins (#15879)
## Description

Adds an optional verification step for password sign-ins on Chatwoot
Cloud. When a sign-in comes from a device that has not been seen before,
the user is emailed a 6-digit code and must enter it to finish signing
in.

The step is off by default and controlled by an installation setting. It
works with existing web and mobile clients without an update. Sign-ins
through SSO, and users who already have two-factor authentication
enabled, are not affected. Once a device has been verified it is
remembered, so the same device is not asked again; that trust is reset
when the account password or email changes.


<img width="659" height="781" alt="image"
src="https://github.com/user-attachments/assets/721fdf03-d0e8-4c3e-a9d2-52b293b03709"
/>


Ref: https://linear.app/chatwoot/issue/CW-8225

## Type of change

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

## How Has This Been Tested?

- Request specs covering when the step is required versus skipped,
single-use codes, the attempt limit, the trusted-device path, and that
verification always resolves to a normal sign-in or a clear error.
- Service and mailer specs for the code and email flows.
- Frontend unit tests for the verification screen.
- Manual end-to-end in a browser: a new-device sign-in shows the email
step, the emailed code completes sign-in, and a return visit on the same
device skips it. On-device mobile confirmation is pending.

## 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
2026-09-18 16:17:39 -07:00
Sivin Varghese
e0d297e5c2 feat: allow editing help center article slugs (#15896) 2026-09-19 04:41:43 +05:30
Sivin Varghese
60759db42d feat: add analytics event for Open billing on past-due banner (#15894) 2026-09-19 04:40:20 +05:30
Sivin Varghese
ac33331c42 feat: move WhatsApp campaign create and edit to a full page (#15409) 2026-09-19 00:09:08 +05:30
Sony Mathew
48df202e9d feat: show who suspended an account (#15874)
Account suspension history now records the signed-in super admin's user
ID and shows their name in a **Suspended by** column beside the reason.
Editing a reason preserves the original actor. Adding details to an
already-suspended legacy account keeps the actor unknown; older entries
and entries for deleted users also show **Unknown user**. User names are
loaded together in one query, and only English source strings are added.

## Closes

No linked ticket.

## Type of change

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

## How to test

1. In Super Admin, edit an active account, suspend it with a category
and reason, and save. Open the account and confirm that the suspension
history shows your name beside the reason.
2. Sign in as another super admin and edit the reason. Confirm that the
original suspending user's name is preserved. Reactivate and suspend the
account again; confirm that the new entry shows the second admin's name.
3. Open an account with older suspension history that has no recorded
user. Confirm that the history still renders and shows **Unknown user**.

## What changed

- Record the actor only when an active account is suspended, and show
their name alongside each suspension reason.
- Replace the MFA suite's hardcoded `000000` invalid-code fixtures with
a value that cannot be generated as an OTP. The failed CI run generated
`000000` as a valid code, so its rejection test incorrectly expected an
error.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-17 19:21:37 -07:00
Sony Mathew
d96170acb1 feat: show cloud payment warnings (#15852)
## Description

Show past-due payment warnings for Cloud accounts as soon as recovery is
needed. Administrators can open billing and resolve payment; agents are
directed to their administrator. The billing page hides the renewal date
until the subscription recovers.

This is the base PR for Cloud support, targeting `develop`. Self-hosted
installation warnings and the Hub dependency are isolated in
https://github.com/chatwoot/chatwoot/pull/15859, stacked on this branch.
Cloud support can be deployed independently of Hub.

## What changed

- Share account payment status between the app-wide banner and billing
page, with administrator-only billing actions and refresh on focus,
reconnect, and billing events. Shopify accounts retain their existing
billing experience.
- Reconcile canonical Stripe subscription state under an account lock,
support item-level subscription fields, preserve replacement
subscriptions, and prevent duplicate renewals from resetting Captain
usage twice.
- Preserve the free-plan cancellation flow and record cancellation once
per subscription, distinguishing payment failure, voluntary
cancellation, trial non-conversion, and unknown history. Mixed invoices
and post-cancellation payments do not establish paid churn; attribution
updates preserve billing records.

Stripe retry, cancellation, and invoice settings remain unchanged. Live
retry count/timing and endpoint configuration still need verification.
PLA-205's existing configuration behavior is preserved.

## Closes

Related to
[PLA-218](https://linear.app/chatwoot/issue/PLA-218/add-past-due-warnings-and-verify-payment-recovery).
The ticket remains open for rollout and live Stripe configuration
verification.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)

## How to test

1. Open a Cloud account with a past-due Stripe subscription, including a
future period end. Check the app-wide warning and billing-page recovery
action. Switch between administrator and agent memberships; agents
should see contact-administrator wording without a billing action or
billing API access. Verify Shopify behavior is unchanged.
2. Recover payment and return to the app. Both warnings should clear and
renewal details should reappear. Repeat renewal and delayed failure
events; plan, usage, and credits should remain correct. A cancellation
for an old subscription must preserve its replacement.
3. Inspect cancellation records: paid payment failure and voluntary
cancellation are separate from trial non-conversion, free subscriptions,
and ambiguous payment history.
4. Open a self-hosted installation and confirm this base PR adds no
installation payment warning.

## Screenshots

[Cloud administrator, mobile, agent, and recovered-state
screenshots](https://github.com/chatwoot/chatwoot/pull/15852#issuecomment-5706659815).

## 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
- [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: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
2026-09-17 19:17:58 -07:00
Silva Dev BR
91fe086381 fix: preload inbox conversation filter (#14523)
## Summary
- Normalize the conversation inbox route param before initializing the
inbox filter
- Add coverage for string route params so the selected inbox matches
numeric inbox options

Fixes #14409

## Tests
- corepack pnpm vitest run
app/javascript/shared/composables/specs/useFilter.spec.js --no-coverage
--no-cache
- .\\node_modules\\.bin\\eslint.CMD
app/javascript/shared/composables/useFilter.js
app/javascript/shared/composables/specs/useFilter.spec.js

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-09-17 19:07:01 -07:00
Sony Mathew
9d46d1dda9 fix: skip widget contact creation for crawlers (#15875)
## Description

Known crawlers that directly load an embedded widget no longer create
anonymous contacts or contact-inbox records. They receive an empty `204
No Content` response with `X-Robots-Tag: noindex` before visitor-token
processing and contact creation. Normal visitors, including requests
without a user-agent header, keep the existing widget behavior.

Follows the merged #15873 and targets `develop`. The existing `Disallow:
/widget` rule remains in place. This adds a server-side check using the
existing Browser library; it does not detect crawlers that disguise
themselves as ordinary browsers. Website-token validation and
suspended-account checks still run before the crawler response.

## Closes

Related to
https://linear.app/chatwoot/issue/CW-8224/prevent-login-page-indexing-on-non-cloud-installations

## Type of change

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

## How Has This Been Tested?

1. Load `/widget?website_token=<valid-token>` from a normal browser. The
widget initializes and creates an anonymous contact. Reload with its
existing `cw_conversation` token and confirm the same contact is reused.
2. Request the same widget URL with a `Googlebot` or `bingbot`
user-agent. Confirm an empty `204` response with `X-Robots-Tag:
noindex`, and no new contacts or contact-inbox records, including when
an invalid `cw_conversation` token is supplied.
3. Confirm invalid website tokens still return `404` and suspended
accounts still return `401`. Requests without a user-agent continue to
initialize normally. Verify these behaviors with Enterprise enabled and
disabled.

## 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-09-17 18:57:15 -07:00
Vladimir Babin
be992c33cd docs(env): document WEB_CONCURRENCY in .env.example (#15856)
Closes #15819.

## Problem
`config/puma.rb` reads `WEB_CONCURRENCY` to run Puma in clustered mode:
```ruby
workers ENV.fetch("WEB_CONCURRENCY", 0)
preload_app!
```
But `WEB_CONCURRENCY` is absent from `.env.example`, so most self-hosted
installs leave it unset and Puma defaults to `workers=0` (single
process, only `RAILS_MAX_THREADS` threads). Under the GVL, CPU-bound
requests then serialize and can queue/timeout even on multi-core
servers.

## Change
Add a commented `WEB_CONCURRENCY` entry next to `RAILS_MAX_THREADS` in
`.env.example`, explaining clustered mode and how to size it.

The line is **commented out**, so default behavior is unchanged — this
only documents and surfaces the knob to self-hosters, leaving the actual
default value as a deployment choice. Docs-only, no code paths touched.
2026-09-17 17:28:54 -07:00
Sivin Varghese
14c9dfddde fix: define route in ChatList before reading query filters (#15878) 2026-09-18 05:57:57 +05:30
breken
e81273cc11 fix(facebook): preserve Graph profile names (#15841)
## Description

Fixes #15840.

The reporter traced valid Meta Graph response shapes that the current
mapping discards: a profile with only `name` becomes `John Doe`, while a
profile with only `first_name` gains a fabricated `Doe` surname. This
change preserves a nonblank full `name` first, otherwise joins only
nonblank first/last components, and keeps `John Doe` solely as the
all-fields-missing fallback.

Regression specs cover full-name-only and single-part-name responses.
This intentionally does not rewrite historical placeholders because
distinguishing generated placeholders from agent-edited names needs a
separate product policy.

## Type of change

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

## How Has This Been Tested?

- `git diff --check` passes.
- Specs were added but not run locally because the bundle/runtime is
unavailable in this environment.

## 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 my fix is effective
- [ ] New and existing unit tests pass locally with my changes

> Built by breken, your AI support engineer - breken.ai - this one's on
us.
2026-09-17 17:25:26 -07:00
Sivin Varghese
4808f8f9c0 chore: open drill-down conversations in the dashboard (#15832) 2026-09-18 05:33:57 +05:30
Sony Mathew
4423a981d0 Merge branch 'release/4.18.0' into develop 2026-09-17 16:49:31 -07:00
Sony Mathew
a9552e8802 Bump version to 4.18.0 2026-09-17 16:48:55 -07:00
Sony Mathew
fcfad2d2b8 fix: limit app, widget, and admin search indexing (#15873)
## Description

Prevent non-Cloud login and application pages, Super Admin, and
installation setup pages from being indexed. Cloud application pages
remain eligible for indexing. Widget pages always include `noindex`, and
their existing crawl exclusion is preserved to avoid creating contacts
from crawler requests. Public Help Center homepages and articles retain
their existing indexing behavior, including when a widget is embedded.

A public Rails route replaces the static robots.txt file and retains
`Disallow: /widget`. The application layout uses the existing Cloud
detection helper to apply `noindex` conditionally; widget, Super Admin,
and setup pages include it on every installation. Public Help Center
layouts remain unchanged.

Because widget crawling remains blocked, crawlers cannot reliably read
its `noindex` tag to remove already-indexed widget URLs. Allowing widget
crawling safely requires a separate change to make widget GET requests
create no records.

## Closes

Closes
https://linear.app/chatwoot/issue/CW-8224/prevent-login-page-indexing-on-non-cloud-installations

## Type of change

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

## How Has This Been Tested?

1. On a self-hosted installation, open the page source for `/app/login`,
`/app/login/sso`, `/app`, `/`, and
`/widget?website_token=<valid-token>`. The HTML head includes `<meta
name="robots" content="noindex">`, including when the manifest is
disabled.
2. Set the installation's `DEPLOYMENT_ENV` to `cloud` with Enterprise
enabled and reload. The application pages no longer include the
directive, while the widget still includes it. Community installations
retain the directive on application and widget pages.
3. Open `/super_admin/sign_in`, sign in, and inspect `/super_admin` and
`/super_admin/accounts`. All include `noindex` on Cloud, self-hosted
Enterprise, and Community installations.
4. On a fresh installation, open `/` and follow the redirect to
`/installation/onboarding`. The setup page includes `noindex`.
5. Open a public Help Center homepage, article, and custom-domain
homepage using either portal layout, including with a widget attached.
None receives the directive. Plain article previews remain unaffected.
6. Open `/robots.txt` without signing in. It returns plain text with
`User-agent: *` and `Disallow: /widget`, including in API-only
deployments. Other paths remain crawlable.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-17 16:45:37 -07:00
Vishnu Narayanan
7d581dc8c4 Merge commit from fork
* fix: run pre-authentication checks on header-supplied sign-in credentials

* fix: normalize email in SAML password-auth guard
2026-09-17 15:02:14 -07:00
Sony Mathew
aad2791b48 Merge commit from fork
* fix: authorize conversations before macro execution

* fix: log skipped macro executions and cover authorization
2026-09-17 14:39:18 -07:00
Linas Baublys
7d9d17a8de fix: include widget ET translation files (#14701)
Include ET translation files

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-09-17 11:35:47 -07:00
Captain
d0ca2d9672 chore: Update translations (#15848)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-09-17 11:29:05 -07:00
Nguyen Tan Hung
39b9d2014b fix(facebook): handle messenger postbacks (#14115)
## Description

This fixes Facebook Messenger postback events not being received by
Chatwoot inboxes.

Previously, Chatwoot subscribed Facebook pages without the
`messaging_postbacks` webhook field, so actions like `Get Started` and
button postbacks were not delivered. This change adds the missing
subscription field, registers a Messenger `:postback` handler, and maps
postback payloads into the existing Facebook inbound message flow.

Fixes #8821

## Type of change

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

## How Has This Been Tested?

Manual reproduction steps:
1. Create or connect a Facebook Page inbox in Chatwoot.
2. Configure a Messenger `Get Started` button or a button that sends a
postback.
3. Trigger the postback from Messenger.
4. Confirm the event is received and created through the existing
Facebook inbound message pipeline.

Automated coverage added:
- Added a model spec to verify `messaging_postbacks` is included in
Facebook page webhook subscriptions.
- Added a builder spec to verify a Messenger postback creates an
incoming message using the postback title.

Note:
- I could not run the full local Ruby test suite in the current
environment because Ruby/Bundler was not available in the active shell.

## 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-09-17 14:15:45 +04:00
breken
5b7038b950 fix: return 404 instead of 500 for unknown portal slugs (#15798)
Fixes #15797 - credit to @Uankur for the precise report and diagnosis
(exact citations, repro curls, and the proposed direction); this PR
implements the fix they sketched, verified independently against current
`develop`. @Uankur - would love your eyes on this.

## Problem

`GET /api/v1/accounts/:account_id/portals/:slug` returns HTTP 500 when
the slug does not exist in the account. `fetch_portal` uses
`find_by(slug:)`, which returns nil instead of raising, and `show` then
calls `@portal.articles` on nil. The blast radius is wider than `show`:
`fetch_portal` runs as a `before_action` for every portal action except
`index`/`create`, so `update`, `destroy`, `archive`, `logo` and
`send_instructions` hit the same nil crash (EE `ssl_status` too).

## Fix

One-token change: `find_by` -> `find_by!`.
`ActiveRecord::RecordNotFound` then flows through the existing
`handle_with_exception` rescue, which renders the standard 404
`"Resource could not be found"` - the same pattern sibling controllers
already use (`labels_controller` uses `find`, and the public portals
controllers use `find_by!(slug:)`). The lookup stays scoped to
`Current.account.portals`, so a slug belonging to another account also
returns 404, matching the reporter's expectation. Pundit authorization
is class-level here and unchanged.

## Tests

Two request specs added to `portals_controller_spec.rb`: unknown slug ->
404 with the standard body, and cross-account slug -> 404.

**Test status (honest):** the specs are written but NOT executed - no
Ruby toolchain was available in our environment. Verification was
static: the nil path, the `handle_with_exception` RecordNotFound -> 404
rescue, and the sibling/public-controller `find_by!` precedent were all
confirmed against current `develop` (`2f1ed80f89`). The one-token change
mirrors the repo's own usage elsewhere.

> Built by breken, your AI support engineer - breken.ai - this one's on
us.
2026-09-16 16:22:44 -07:00
qsebas
2be8c3ac9b fix(sessions): read MAX_USER_SESSIONS from ENV dynamically (#15062)
Ruby 3.4 with YJIT enabled inlines constant values at compile time, so
`stub_const` in tests can't replace a class-level constant that YJIT has
already cached — the spec calls still see the original value and the
session-limit tests return 200 instead of 409.

## What changed

- Removed the `MAX_SESSIONS` class constant from
`DeviseOverrides::SessionsController`
- `sessions_limit_reached?` now reads `ENV.fetch('MAX_USER_SESSIONS',
25).to_i` directly on each call (with a guard for `<= 0`)
- Updated the spec to use `with_modified_env` instead of `stub_const` so
the limit is configurable at test time without hitting the YJIT cache

## How to test

Run the session limit enforcement specs:

```
bundle exec rspec spec/controllers/devise_overrides/sessions_controller_spec.rb
```

All tests should pass, including on Ruby 3.4 with YJIT enabled.

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-09-16 14:58:09 -07:00
Gurjit Singh
4218dc6793 fix(security): sanitize XSS-prone HTML rendering and markdown output (#14050)
Summary
This PR fixes XSS vulnerabilities by sanitizing unsafe HTML rendering
paths:

Replaced unsafe
[v-html](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)/[innerHTML](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)
rendering with
[v-dompurify-html](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)
in captain assistant UI components
Sanitized signup terms HTML before rendering in the signup form
Added server-side markdown sanitization in
[chatwoot_markdown_renderer.rb](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)
before marking HTML safe
Added regression coverage for markdown XSS sanitization.
Changed files

[MessageList.vue](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[ScenariosCard.vue](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[Index.vue](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[Form.vue](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[chatwoot_markdown_renderer.rb](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[chatwoot_markdown_renderer_spec.rb](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)
Testing
bundle exec rspec spec/lib/chatwoot_markdown_renderer_spec.rb
Optional frontend validation by building the dashboard and confirming
the updated components render safely without unsafe HTML injection
paths.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-09-16 14:48:59 -07:00
Sivin Varghese
2eabc740d8 feat: navigate between a contact's conversations from the thread (#15427) 2026-09-17 03:04:24 +05:30
Mazen Khalil
a641b0e4fa fix: Widget DirectUpload CSRF error (#13447)
## Problem
Widget file uploads via ActiveStorage DirectUpload fail on production
with:

```text
ActionController::InvalidAuthenticityToken (Can't verify CSRF token authenticity.)
```

This occurs because `Api::V1::Widget::DirectUploadsController` inherits
from `ActiveStorage::DirectUploadsController` →
`ActiveStorage::BaseController`, which has protect_from_forgery with:
:exception enabled.

While CSRF works on same-origin, it fails in production because the
widget is embedded in an iframe on third-party customer sites
(cross-origin)


## Solution
Skip CSRF verification for the widget direct uploads endpoint - this is
safe because:

- Widget endpoints authenticate via website_token + X-Auth-Token
(token-based auth)
- CSRF protection is designed for session-based auth, not token-based
auth

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-09-16 14:28:41 -07:00
Tanmay Deep Sharma
eb0d524de2 fix(whatsapp): upload attachments to the media endpoint and send media_id (#15702)
WhatsApp Cloud attachments are now uploaded to Meta's media endpoint and
sent by `media_id` instead of a public `link`. Meta no longer downloads
the file from the Chatwoot server, so outbound media stops failing
intermittently with `131053: Media upload error` on hosts that share an
ASN with other Chatwoot/Evolution API instances — Meta's `fwdproxy` rate
limits per destination ASN, not per WABA account. It also removes the
secondary failure where `fwdproxy` does not follow ActiveStorage's `302`
redirect and reports `Unsupported mime type text/html`.

Operators who need the previous behaviour can set
`WHATSAPP_MEDIA_UPLOAD_STRATEGY=link`. No migrations, no config required
on upgrade.

Supersedes #15069 by @AdarshJ173, which established this approach and
the `media_id` direction. The review points raised by @nestordavalos on
that PR are addressed here.

## Closes

- Closes #13540
- Supersedes #15069

## How to reproduce

On a WhatsApp Cloud inbox, send an image, document or voice note from an
instance whose hosting provider shares an ASN with other Chatwoot
deployments (e.g. Hostinger, ASN 47583). Meta's webhook returns:

```
Downloading media from weblink failed with http code 429
ratelimit reason: Request ratelimit by fwdproxy
[GlobalCountingRatelimiter] Request hit ratelimit policy — destination matcher asn_list: 47583
```

After this change the file is uploaded first and the message references
the returned `media_id`, so Meta never fetches it from us.

## What changed

- **New `Whatsapp::MediaUploadService`** — streams the blob to `POST
/{version}/{phone_number_id}/media` as multipart form data and returns
`{ 'id' => media_id }`, or `nil` when the upload is disabled or fails.
- **`WhatsappCloudService#build_attachment_content`** sends the returned
media object, falling back to `{ 'link' => download_url }` so a
media-endpoint failure never blocks a message.
- Uses `Faraday::Multipart::FilePart`, matching
`Telegram::SendAttachmentsService`, the codebase's existing multipart
upload. The file part carries an explicit filename and content type.
- Reads the blob through `blob.open` and streams it via Faraday's
`CompositeReadIO`, so large attachments are never buffered in memory and
any ActiveStorage backend works.
- Follows `WHATSAPP_API_VERSION` via `GlobalConfigService`, matching
`calls_phone_id_path` in the enterprise provider, rather than pinning a
version in source.
- Falls back to the link on `Faraday::Error` or a non-success response
only; other errors surface instead of silently degrading.
- Logs one warning naming the fallback and Meta's own error, including
for non-JSON error pages: `[WHATSAPP] Media upload failed, falling back
to link for account … attachment …: HTTP 429 Request ratelimit by
fwdproxy`.

The `voice`/`caption`/`filename` fields and the existing `audio/opus` →
`audio/ogg` normalisation are unchanged; the normalisation now also
governs the uploaded part's content type.

---------

Co-authored-by: A.Adarsh Jagannath <adarshjagannath777@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-09-16 14:21:27 -07:00
Tejush
a8514e7607 fix: Configure increased redis reconnection attempts (#14704)
Add reconnect attempts configuration for Redis.

## Description

This PR fixes an issue where Chatwoot stops delivering websocket updates
after Redis is restarted.

For the past few months, `needrestart` has been silently restarting
services that depend on upgraded libraries during system updates,
including Redis. This is the case for me, and maybe it will be for other
people as well.

Issue is When Redis restarts, Action Cable loses its Redis subscription
connection.

The root cause is that the Redis client used by Action Cable only
performs a limited reconnect attempt. If Redis is unavailable during
that window, the subscription remains disconnected permanently. As a
result, websocket connections stay open and continue sending pings, but
no real-time updates are delivered to clients.

This change configures Redis subscription reconnection attempts with
exponential backoff, allowing Action Cable to recover automatically from
temporary Redis outages and service restarts.

Closes #14705

## Type of change

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

## How Has This Been Tested?

### Reproduction Steps

1. Deploy Chatwoot v4.8.0.
2. Open the Chatwoot admin panel.
3. Verify that real-time updates are working.
4. Restart Redis:
```sh
sudo systemctl restart redis-server

```


5. Send a new message or trigger a real-time event.

### Before this change

* Websocket connections remain open.
* Only ping frames are exchanged.
* No real-time updates are delivered.
* Chatwoot requires a restart to restore functionality.

### After this change

* Action Cable automatically reconnects to Redis.
* Real-time updates continue working after Redis becomes available.
* No Chatwoot restart is required.

## 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
* [x] Any dependent changes have been merged and published in downstream
modules

## Environment

| Component | Version |
|---|---|
| **OS** | Ubuntu (Noble/24.04) |
| **Ruby** | 3.4.4 |
| **Rails** | 7.1.5.2 |
| **ActionCable** | 7.1.5.2 |
| **redis** (gem) | 5.0.6 |
| **redis-client** (gem) | 0.22.2 |
| **Redis Server** | 8.0.5 |

---------

Co-authored-by: tejush <mac@MacBook-Air.local>
2026-09-16 14:18:39 -07:00
Sony Mathew
f61e9c9172 fix(ci): document RubyLLM audit exception (#15847)
## Description

Restore the dependency-audit CI check by adding a documented exception
for RubyLLM's CVE-2026-67991 on Chatwoot's supported runtime. The change
adds only this advisory to `.bundler-audit.yml`, with the applicability
rationale and removal condition. RubyLLM remains at 1.15.0 and every
other advisory remains subject to the existing audit configuration.

### Advisory verification and applicability

[CVE-2026-67991 /
GHSA-42r3-x6vx-x49x](44784c2953/gems/ruby_llm/CVE-2026-67991.yml)
describes polynomial-time name normalization on Ruby 3.1.x when a very
long crafted class, agent, or tool name reaches the affected operation.
The released 1.15.0 gem contains the regex inline in
`RubyLLM::Tool#name`; the advisory's later `Utils.underscore` reference
does not mean released gems were unaffected.

The exception is based on the application's runtime and actual input
paths:

- **Runtime:** the Gemfile, `.ruby-version`, CircleCI and Docker images
specify Ruby 3.4.4. A bounded local probe of the installed `Tool#name`
method reproduced quadratic behavior on Ruby 3.1.0, but linear behavior
on Ruby 3.4.4. The latter also reports this regex as linear-time,
consistent with [Ruby's regex memoization introduced in
3.2](https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/).
- **Built-in tools:** names come from fixed application classes, rather
than user-generated class names.
- **Custom tools:** [slugs are limited to 64
characters](e16d8f2fa4/enterprise/app/models/captain/custom_tool.rb (L38-L65)).
[Toolable overrides the name
method](e16d8f2fa4/enterprise/app/models/concerns/toolable.rb (L8-L25))
to return that slug directly, bypassing RubyLLM's normalizer.
- **Agent handoffs:** Chatwoot uses `Agents::Agent` from ai-agents; its
handoff tools override naming with a separate lowercase/filter
conversion. Scenario handoff names also have a length budget. Ordinary
prompt/message text does not automatically enter the affected
normalization operation.

Local timings for synthetic all-capital class names:

| Characters | Ruby 3.1.0 | Ruby 3.4.4 |
| --- | ---: | ---: |
| 5,000 | 104 ms | 0.85 ms |
| 10,000 | 430 ms | 1.72 ms |
| 20,000 | 1,676 ms | 3.31 ms |

These measurements characterize the reported regex behavior, not
production capacity. Ruby 3.1.0 was used only for the isolated method
probe; Chatwoot itself was not run on that unsupported version. Live
deployment runtimes were not remotely inspected.

### Why this PR does not upgrade RubyLLM

As of 16 September 2026, stable 1.16.0 still contains the pattern and
still matches the advisory. The [upstream
fix](9d75b033d7)
is released starting with 2.0.0.rc1; the latest release is 2.0.0.rc4,
also a prerelease.

`ai-agents 0.12.0` requires `ruby_llm ~> 1.14`. An isolated Bundler
resolution rejects 2.0.0.rc4. Bypassing the constraint for a
compatibility probe makes `require 'agents'` fail on the removed `param`
method. Chatwoot [requires agents unconditionally during
initialization](e16d8f2fa4/config/initializers/ai_agents.rb (L1-L3)),
so simply widening that constraint can prevent application startup,
including when Captain is unused.

The [2.0
migration](https://github.com/crmne/ruby_llm/blob/v2.0.0.rc4/docs/_reference/upgrading.md)
also affects existing Chatwoot behavior:

| Surface | Required migration |
| --- | --- |
| Captain V2 / scenario handoffs | Replace removed `Tool::Halt`, update
instruction/tool replacement APIs, and preserve runner state and handoff
control. |
| Built-in/custom tools and Copilot | Update `param`/`params`,
parameter-schema readers, tool registration, provider options and
callbacks. |
| Rewrites, replies, summaries, labels and other Captain tasks | Replace
direct token readers; current response conversion can otherwise fail
after a successful provider response. |
| Images and conversation replay | Replace `RubyLLM::Content` with the
new attachment/message API. |
| Structured generation | Read parsed output through `response.parsed`;
current Hash consumers can otherwise produce empty overview points,
blank article fields or raw JSON taglines. |
| OpenAI-compatible endpoints | Make protocol selection explicit: 2.0
defaults to `/responses` instead of `/chat/completions`. Existing
`response_format` options and custom endpoints need adaptation or an
explicit Chat Completions protocol. |
| Request behavior / observability | Validate temperature settings,
token/cost tracing and the renamed model-registry refresh method. |

The isolated request probe confirmed the protocol change and that a
GPT-5.2 request configured with temperature 0.5 sends 1.0 under 1.15 and
0.5 under rc4. No live provider requests were made.

There is no `acts_as_chat`/`acts_as_message` persistence integration in
Chatwoot, so the upstream Rails data migration does not automatically
apply. Embedding/moderation readers used here still exist, and legacy
speech/PDF operations use the separate OpenAI client. RubyLLM remains
MIT licensed. A supported upgrade should be coordinated with ai-agents
instead of being folded into this audit exception.

### Removal condition

Remove the exception when Chatwoot adopts a supported patched
RubyLLM/ai-agents combination, including a recognized compatible 1.x
backport if one becomes available. Reassess it if the supported Ruby
runtime or tool-name construction changes. This records advisory
applicability; it does not claim that RubyLLM 1.15.0 includes the
upstream fix.

The original audit failure was reproduced against ruby-advisory-db
`44784c295391577f25d198a9205eae4ba73ec4da`. The actual changed
configuration now passes the refreshed audit, YAML formatting and
whitespace checks. The prior investigation passed 528 existing focused
specs on this application revision; after adding the exception, all 131
selected Captain base-task, custom-tool and scenario specs passed again.
Existing Rails enum deprecation warnings remain unchanged. No new specs
were added for the configuration-only change.

## Closes

No linked ticket.

## Type of change

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

## How to reproduce

1. On the parent commit, run the CircleCI dependency-audit check with an
updated ruby-advisory-db. It reports RubyLLM 1.15.0 / CVE-2026-67991 and
exits unsuccessfully.
2. Run the same check on this branch. It succeeds with the documented
exception; the gem versions and application behavior remain unchanged.

## 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 (inline
exception rationale and removal condition)
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works (not applicable: audit configuration only)
- [x] New and existing unit tests pass locally with my changes (131
focused existing examples)
- [ ] Any dependent changes have been merged and published in downstream
modules (not applicable: no dependent changes)
2026-09-16 12:51:29 -07:00
Sivin Varghese
e39b5b7080 fix: support opening sidebar items in new tabs (#15833)
# Pull Request Template

## Description

This PR fixes sidebar navigation items not behaving like real links.
Middle-click, Cmd/Ctrl-click, and the browser’s “Open link in new tab”
action now work as expected.

Fixes
https://linear.app/chatwoot/issue/CW-7868/impossibility-to-open-navigation-menu-items-in-a-new-window-or-new-tab

## Type of change

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

## How Has This Been Tested?

### Screenshots

**Before**
<img width="369" height="387" alt="image"
src="https://github.com/user-attachments/assets/280b540a-1c4c-4f7c-b459-17ea8823fd67"
/>


**After**
<img width="369" height="387" alt="image"
src="https://github.com/user-attachments/assets/87a02578-7064-41ae-abd9-2f6bd4cb5a5e"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-16 12:24:52 -07:00
Tanmay Deep Sharma
e16d8f2fa4 fix(voice): answer WhatsApp calls from the conversation when the call is not in the store (#15795)
Answering an incoming WhatsApp call from the call bubble in the
conversation could show "Internal Server Error", and the caller then saw
the call as missed. This happened when the call wasn't in the agent's
calls store: it rang while the agent wasn't online, or it was dismissed
earlier. In that case the dashboard fell back to the Twilio flow and
requested a Twilio token for the WhatsApp inbox.

## Closes

- https://linear.app/chatwoot/issue/CW-8174

## How to reproduce

1. Set your availability to Busy or Offline and receive a WhatsApp call
on an inbox with calling enabled.
2. Open the conversation and click Answer on the ringing call bubble.
3. Before: "Internal Server Error" and the call ends as missed. After:
the call connects.

## What changed

- The call bubble adds the call to the calls store from its own message
data before joining, so the WhatsApp answer path is used.
- `ConferenceController` only resolves Twilio inboxes, so the Twilio
token and conference endpoints return 404 for other inboxes instead of
500.
2026-09-16 06:52:51 -07:00
Muhsin Keloth
d0d033901d fix(twilio): preserve incoming quoted replies (#15838)
Incoming WhatsApp replies through Twilio lose their quoted-message
context, leaving agents unable to tell which earlier message the
customer means. This change displays the referenced message when it
exists in the same conversation.

The Twilio callback now accepts `OriginalRepliedMessageSid` and passes
it to the existing message reply resolver as `in_reply_to_external_id`.

### Things to know

- Applies to incoming quoted replies. Sending quoted replies through
Twilio remains unchanged.
- Uses the existing same-conversation lookup; references to unavailable
messages retain the existing unresolved-reply behavior.
- Previously received messages are not backfilled.

### How to reproduce

1. In a Twilio WhatsApp conversation, reply to an earlier message using
WhatsApp's reply action.
2. Before this fix, Chatwoot displays the reply without the quoted
message even when Twilio supplies its SID.

### How to test

1. Reply to a recent message in an existing Twilio WhatsApp
conversation.
2. Confirm the incoming reply displays the original message in its quote
preview.
3. Click the quote preview to navigate to the original message.

Locally verified by posting a mock Twilio webhook to the callback
endpoint: the incoming message persisted both reply identifiers and
displayed the original message in the conversation UI. Regression
coverage checks that the controller forwards the quoted SID and the
incoming-message service persists both reply identifiers. The focused
Twilio suite passed with 52 examples and no failures; RuboCop and diff
checks also passed.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-16 13:38:54 +04:00
Sony Mathew
3effb7e824 feat: add sorting to filtered conversations (#15753)
## Description

Agents can now use the existing conversation sort menu while viewing
conversations narrowed by advanced filters or saved folders. Changing
the order refetches the filtered result set before pagination, so
oldest/newest, activity, unread, priority, and waiting-time ordering
remain consistent across loaded pages.

## Closes

-
[CW-7862](https://linear.app/chatwoot/issue/CW-7862/lack-of-sorting-options-when-using-filters-in-conversations-tab)

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

## What changed

- Keep the existing order menu available for ad-hoc filtered and
saved-folder conversation lists while hiding the conflicting status
selector.
- Share the server-side sort whitelist between standard and filtered
conversation queries, preserving existing defaults and legacy aliases.
- Send the selected sort through filtered fetches, pagination, and
reconnect refreshes, with client-side ordering aligned to the server.
- Document the optional `sort_by` parameter for the conversation filter
API.

## How to test

1. Open Conversations and apply an advanced filter.
2. Open the sort menu and confirm the existing order options are
available while the status selector is hidden.
3. Change between oldest/newest, unread, priority, and waiting-time
options and confirm the filtered list refetches in the selected order.
4. Open a saved conversation folder and confirm the same sorting
behavior, including after loading another page.

## 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-09-15 18:21:04 -07:00
Vishnu Narayanan
fc5c734b97 feat: resolve IP location for sign-in and sign-out audit logs (#15752)
# Pull Request Template

## Description

Sign-in and sign-out write one audit row per account the user belongs to
using `insert_all!`, which bypasses the `after_create_commit` that
enqueues the IP geolocation lookup added in #15455. Session audits were
the only rows that never got a city or country.

Restoring the callback would undo #15572, which took a 500-account
sign-in from 2016 queries to 519. Since every row from one sign-in
shares a `remote_address` and `request_uuid`, this enqueues a single job
per sign-in that resolves the address once and applies it to that
request's rows in one `update_all`, scoped to accounts with `ip_lookup`
enabled. Constant cost regardless of membership count.

The second commit fixes a defect found while testing. `audit_event_rows`
used `::Audited.store[:current_request_uuid] || SecureRandom.uuid`.
Rails strips an inbound `X-Request-Id` to an empty string when it holds
only punctuation, and `""` is truthy, so those rows were written with a
blank uuid and the new job would skip the batch. Now uses `.presence`.

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

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

New job spec: one lookup applied to every row of a request, isolation
from other requests, a batch spanning eligible and ineligible accounts,
no lookup for ineligible or empty scopes, error swallowing.

Session controller spec: exactly one job enqueued for a user in four
accounts, and rows still share a generated uuid when the request id
sanitises to blank. Verified that spec fails without the `.presence`
fix.

27 examples, 0 failures locally. Rubocop clean.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-09-15 18:18:44 -07:00
Tanmay Deep Sharma
3d7ca0ec0b fix(contacts): default the contacts list to most recent activity first (#15827)
The Contacts page could fail to load on larger accounts. When a user has
never picked a sort, the list asked for contacts ordered by **oldest**
activity first. No index can serve that order, so every page load read
every contact in the account just to return 15. When those rows aren't
cached, or the database disk is under load, that runs past the 14s
statement timeout and the page never loads. The page now defaults to
**most recent** activity first. The existing `(account_id,
last_activity_at DESC NULLS LAST)` index serves that order, so a page
load reads about 20 pages whatever the account's size. Users who picked
a sort keep it.

## Closes

- No existing issue. Found via recurring `PG::QueryCanceled` /
`Rack::Timeout` errors on `contacts#index`.

## How to reproduce

1. On a large account, open **Contacts** as a user who has never changed
the sort.
2. The page sends `GET
/api/v1/accounts/:id/contacts?sort=last_activity_at`, which becomes
`ORDER BY last_activity_at ASC NULLS LAST LIMIT 15`.
3. Postgres fetches and sorts every listed contact of the account. When
those pages come from disk, the request hits the statement timeout.

## What changed

- `ContactsIndex.vue`: when no `contacts_sort_by` UI setting is saved,
the default sort is now `-last_activity_at` instead of
`last_activity_at`.
- Measured on production for a ~57k-contact account:
- old default: 57,768 pages read per load (56 ms cached, about 24s from
disk at the measured ~0.42 ms per page);
  - new default: an index scan that stops after 15 rows.
- Contact search uses the same plan for either sort direction (trigram
bitmap scan, then a sort of the matches), so it is unaffected.
- Known trade-off: on accounts made up mostly of anonymous visitors, the
new order walks more of the activity index before finding 15 listed
contacts. A partial index restricted to listed contacts removes that
cost if it shows up.

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-09-15 12:37:18 -07:00
Shivam Mishra
a24a498b7e fix(whatsapp): temporarily disable contact-info action during browsing (#15826)
Temporarily hide the WhatsApp Request Contact Info action in
conversation responses to remove message-history lookups from routine
inbox browsing. Conversation payloads keep the existing fields but
return `available: false`, `reason: null`, and `delivery_mode: null`.

This is an alternative to #15825. Instead of displaying an action that
can later fail because of a pending request in older history, this
mitigation hides the dedicated action entirely while a cheaper
pending-state approach is considered.

## What changed

- Return disabled contact-info availability from the shared conversation
partial without invoking eligibility checks.
- Disconnect the batch lookup from list rendering while retaining the
service code for re-enabling the feature.
- Preserve validation for direct API requests and manually selected
contact-info templates. This disables the dedicated UI action, not the
backend capability.

## How to reproduce

Open a WhatsApp Cloud conversation for a BSUID contact without a phone
number. Before this change, conversation rendering checks message
history to determine whether to show or disable Request Contact Info.
After this change, the dedicated action is hidden and rendering no
longer runs that lookup.

Check conversation lists, filtered lists, contact conversation lists,
and individual conversation responses. Each should return the disabled
contact-info payload.
2026-09-15 16:55:16 +04:00
Shivam Mishra
16771f6ede fix: prevent stale metadata from overwriting filtered counts (#15808)
Fixes advanced-filter totals reverting to open-conversation counts when
requests finish out of order. Count updates now follow request order
within the active view, preserving newer event refreshes and allowing
useful metadata updates when a list refresh fails.

Adds regression coverage for request ordering, failures, pagination, and
cached assignee-tab switches.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-09-15 15:44:50 +05:30
Shivam Mishra
159658c007 revert: fix email prompt reauthorization for IMAP authentication failures" (#15823)
Reverts chatwoot/chatwoot#15812
2026-09-15 12:45:53 +04:00
Shivam Mishra
1f49e79cca fix(inbox): allow SMTP configuration without IMAP (#15783)
Email inboxes can now configure custom SMTP while using forwarding for
incoming mail. Disabling IMAP also preserves the existing SMTP
configuration, so changing the inbound method does not disable outbound
delivery.

## What changed

- Show SMTP settings for email inboxes independently of IMAP.
- Keep SMTP settings untouched when saving IMAP configuration.
- Replace the IMAP prerequisite message with guidance about receiving
mail through IMAP or forwarding.
- Add regression coverage for SMTP visibility and preserving SMTP when
disabling IMAP.

## How to test

1. Open an email inbox that receives mail through forwarding, with IMAP
disabled.
2. Open Configuration and confirm SMTP settings are available.
3. Configure a valid SMTP provider, save, and reload. Confirm IMAP stays
disabled and SMTP stays enabled.
4. Send a reply and confirm delivery through the configured SMTP
provider.
5. In an inbox with both IMAP and SMTP enabled, disable IMAP and save.
Reload and confirm SMTP remains enabled.
2026-09-14 23:15:38 -07:00
Vishnu Narayanan
6548045e22 revert: local calendar dates in saved filters (#15815)
Date filters return to their previous UTC-based behavior. This reverts
the timezone-aware calendar-date handling introduced in #15784 across
saved filters, live filtering, unread counts, and the filter API schema.

Reverts #15784
2026-09-14 13:17:33 -07:00
Shivam Mishra
0820f3c29e fix(email): prompt reauthorization for IMAP authentication failures (#15812)
Google, Microsoft, and legacy Google email inboxes now prompt for
reconnection after repeated IMAP authentication rejections. Previously,
these failures were only logged, leaving affected inboxes unable to
receive email without showing a reconnection prompt.

Counts explicit authentication failures, Gmail's invalid-credentials
response, and Microsoft's code-less authentication rejection toward the
existing 10-error threshold. Temporary failures and other IMAP providers
retain their existing behavior.
2026-09-14 22:39:16 +05:30
Shivam Mishra
f452298a8f fix(filters): use local calendar dates in saved filters (#15784)
Date filters now keep conversations on the calendar day the user
expects. Applying or saving a filter records the browser's timezone, so
a conversation started late in the evening is no longer excluded just
because its UTC date is already the next day.

### The bug

A conversation created on **September 7 at 9:49 PM in Brasília** is
stored as **September 8 at 00:49 UTC**. A folder with `Created at >
September 6` and `Created at < September 8` compared the UTC date and
excluded it. The September 7 report used the viewer's timezone and
included it, leaving the folder with three conversations while the
report showed four.

### The fix

- Save the browser's IANA timezone with `Created at` and `Last activity
at` filter conditions. Named timezones account for daylight-saving
changes on the date being filtered.
- Convert stored timestamps to that timezone before comparing calendar
dates, and use the same timezone for `Days before`.
- Apply the same date comparison in frontend filtering. Saved-folder
unread counts use the same backend conditions.
- Preserve the saved timezone when editing folders or contact segments.
The shared date-filter path also fixes contact timestamp filters.

Existing folders and API filters without a timezone retain their
previous UTC behavior. **Open and update an existing folder once to
adopt timezone-aware filtering.** Date-only custom attributes are not
timezone-converted, and automation payload generation keeps its existing
behavior.

### How to reproduce

1. Use a browser in `America/Sao_Paulo` and a conversation created at
`2026-09-08 00:49 UTC`, which appears as September 7 at 9:49 PM locally.
2. Apply `Status = All`, `Created at > September 6`, and `Created at <
September 8`, then save the folder.
3. Before this fix, the conversation is missing even though it appears
in the September 7 report. With this fix, it appears in the folder and
stays there after reloading.
4. Edit the saved folder and update its name. Its timezone and matching
conversations should stay the same.

### Closes

Support report:
https://app.chatwoot.com/app/accounts/1/conversations/91709
2026-09-14 15:50:08 +05:30
Shivam Mishra
d3e4ff2dc1 fix: load ChatwootHub before stubbing edition in specs (#15785)
Fixes the backend FOSS CI failures caused by the paid self-hosted plan
specs added in #15760. PRs containing those specs can fail before the
plan checks even run.

The FOSS job removes Enterprise code. These tests then pretend
Enterprise is installed, and loading `ChatwootHub` for the first time
tries to load the missing Enterprise extension. That crashes with
`undefined method 'const_defined?' for false`.

The fix explicitly loads `ChatwootHub` before the tests override the
edition checks. This lets it load using the actual installation state,
then allows each test to simulate its plan as intended.
2026-09-14 13:20:54 +04:00
Shivam Mishra
2f1ed80f89 fix(captain): refine overview reporting and design (#15722)
Refines the Captain overview with clearer reporting periods,
comparisons, and knowledge coverage. The new overview remains behind the
localStorage toggle.

- Add This week and Last week, group day/week/month options, and reveal
ranges as data becomes available from August 11, 2026.
- Align resolution trend comparisons by weekday and time of day, show
both periods’ dates in tooltips, and omit comparisons before tracking
began.
- Show a muted dash with an explanatory tooltip for seven-day durability
in Last 7 days and This week.
- Use purple for the resolution trend and muted purple for comparison
bars.
- Simplify knowledge coverage labels, use a continuous progress bar, and
align approved and suggested FAQ counts.
- Soften the coverage banner, separate its action and dismiss buttons,
and limit it to coverage below 75%.
- Keep summary points around 180–220 characters and reserve three lines
of space to prevent layout shifts.

Enable the new overview in the browser console:
```js
localStorage.setItem('captain_overview_v2', 'true');
location.reload();
```

Switch back:
```js
localStorage.removeItem('captain_overview_v2');
location.reload();
```

Closes
https://linear.app/chatwoot/issue/DESN-65/overview-page-design-feedback
2026-09-11 14:44:46 +05:30
Muhsin Keloth
bacb65d4f6 fix(whatsapp): show backend errors when calls fail (#15766)
When a WhatsApp call fails, the call button now displays the backend's
error message instead of the generic “Request failed with status code
422” toast. This lets agents understand failures such as unavailable
business-initiated calling.

The existing error-message fallbacks remain available when the response
has no explanation. This changes only error presentation; calling
eligibility and consent handling remain unchanged.

### How to reproduce

1. Open a contact with a WhatsApp inbox that cannot initiate business
calls.
2. Click Call and select the inbox if prompted.
3. Previously, the toast showed only the HTTP 422 error even though the
response contained the reason.

### How to test

Attempt a WhatsApp call that returns a backend error. Confirm that the
toast shows the response's error message. If no backend message is
provided, confirm that the existing error fallback is still shown.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-10 17:03:58 +04:00
Sojan Jose
cca63c2f2c fix: simplify Captain eligibility checks for paid self-hosted plans (#15760)
While looking through #13340 and the recent Premium fix in #15727, I
noticed we’re handling Enterprise and Premium as separate cases where
the intent is simply “paid self-hosted.”

This adds `ChatwootApp.self_hosted_paid?` and uses it consistently
across Captain reply search, model overrides, settings access,
new-account defaults, and document sync eligibility.

The existing sync configuration and `enterprise` fallback stay
unchanged. No new self-hosted configuration or intervals are introduced.
Cloud behavior and Premium seat limits remain unchanged.

## How to test

- Verify the affected Captain features are accessible on Premium and
Enterprise, but not Community.
- Confirm the installation model override and new-account Captain
defaults work on both paid plans.
- Confirm document sync still uses the existing configuration.
2026-09-10 17:46:51 +05:30
Sony Mathew
840fb5dd04 refactor: group Captain runner options (#15707)
This follow-up gives the Captain assistant runner a typed extension
point for request-scoped behavior. It keeps the runner constructor
stable while preserving existing playground, Copilot reply-suggestion,
and conversation-response behavior.

This PR is stacked on #15652 and should be reviewed after that PR.

## Closes


[CW-8057](https://linear.app/chatwoot/issue/CW-8057/make-the-captain-playground-better)

## What changed

- Added a typed RunOptions value object for callbacks, source,
burst-protection context, and playground runtime configuration.
- Updated every production runner construction path to use RunOptions.
- Removed the Metrics/ParameterLists suppression from
AgentRunnerService.
- Updated focused service, job, request, and Copilot coverage for the
new boundary.

## How to test

1. Open a Captain V2 playground and send a message with temporary test
setup enabled; confirm the response and run details still appear.
2. Generate a Copilot reply suggestion for a conversation; confirm the
suggestion is returned normally.
3. Send a customer message to a Captain-enabled inbox; confirm Captain
generates one response and message-burst protection remains active.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-09-10 16:04:00 +05:30
Sojan Jose
6b72216fec feat: expose Captain in assignment dropdown (#15437)
Adds the Captain assistant attached to an inbox to the conversation
assignment dropdown and lets agents explicitly assign conversations to
it through the typed AI-assignee contract.

## Closes

-
https://linear.app/chatwoot/issue/CW-7452/add-captain-to-the-assignment-dropdown

## Why

Captain ownership is supported by the backend, but agents need an
explicit way to assign or hand a conversation back to the inbox's
Captain assistant. Manual assignment is a human decision and should not
be blocked by the audience, business-hours, response-quota, or
external-bot rules used for automatic Captain engagement.

## What this change does

- Replaces the Agent Bot-only assignable-agents opt-in with
`include_ai_assignees`, returning typed User, AgentBot, and Captain
owners.
- Adds the Captain assistant to the conversation assignment dropdown
when the same assistant is attached to the requested inbox.
- Accepts a manual Captain assignment only when the selected assistant
is attached to the conversation inbox.
- Assigns Captain as the AI owner, clears the human owner, and moves the
conversation to `pending`.
- Removes the temporary Captain assignment rollout guard.
- Displays Captain-owned conversations with the AI-assignee icon in
conversation cards.
- Keeps automatic Captain engagement and eligibility behavior unchanged.
- Leaves human-only and bulk assignment surfaces unchanged.

This PR is stacked on #15421.

## Validation

1. Attach a Captain assistant to an inbox.
2. Open a conversation from that inbox and expand the assignee dropdown.
3. Verify the Captain assistant appears alongside human agents and Agent
Bots.
4. Assign Captain and verify it becomes the displayed owner and the
conversation moves to `pending`.
5. Verify an assistant that is not attached to the conversation inbox
cannot be assigned.
6. Verify Captain is not offered in human-only or bulk assignment
surfaces.
2026-09-10 01:46:26 -07:00
Muhsin Keloth
726af1265f feat(shopify): add gated billing infrastructure (#15756)
This is the infrastructure stage of Shopify installation and billing,
split from #13550 so it can be reviewed and merged before the shared
signup and authentication changes. It adds gated Shopify subscription
verification, billing details, plan mapping, and lifecycle cleanup for
Shopify-billed accounts.

Existing accounts default to Stripe billing. The new Shopify billing
flow requires both `ENABLE_SHOPIFY_INTEGRATION` and the account-level
`shopify_integration` feature. The global switch defaults to disabled.
This PR is not a rollout of the complete merchant installation journey.

### Related

- Stacked follow-up: #13550, which should merge only after this PR and a
final end-to-end sanity check.
- Related:
https://linear.app/chatwoot/issue/CW-6500/support-shopify-app-store-install-flow-for-merchant-initiated
- Related:
https://linear.app/chatwoot/issue/PLA-170/resolve-shopify-app-store-billing-rejection

### What changed

- Shopify domain validation, feature gates, API clients, secure
pending-install storage, and subscription snapshots.
- Shopify billing identity, hosted pricing URLs, billing summary API/UI,
plan mapping and fixed plan limits.
- Subscription sync/reconciliation and signed uninstall/privacy cleanup.
- Shopify configuration fields and integration availability checks.

### Things to know

- Shared signup/auth controllers, password reset, resend confirmation,
mailers, user/notification callbacks, Google OAuth, MFA, and auth
navigation are unchanged from `develop`.
- The minimal shared integration points include account billing
identity, account serialization, billing controller/routes/UI,
provider-specific plan resolution, Stripe-event protection, hook
validation, configuration screens, and the hourly scheduler. These still
need review; this PR is not exclusively new files.
- The complete installation/callback/signup wiring remains in #13550. Do
not enable this as a standalone merchant rollout.
- No database migration or schema change is included. The duplicate-hook
cleanup and unique indexes remain in #13550, along with their
database-level tests.
- Captain reservation accounting, IMAP processing changes, notification
callback changes, and global configuration-loader changes remain in
#13550.
- Uninstall/privacy cleanup intentionally works while the global feature
is disabled.
- The existing Stripe plan feature catalog is preserved;
Shopify-specific entitlement cleanup has its own feature set.

### How to test

1. With Shopify disabled, verify ordinary signup, login, confirmation
resend, password reset, and existing Stripe billing continue working;
Shopify should be absent from the integrations list and direct details
should be unavailable.
2. In a disposable cloud-mode test instance, configure the Shopify
Partner API, app handle, and test plan catalog. Enable the global and
account Shopify flags for a pre-provisioned Shopify-origin account with
a connected development-store hook.
3. Open Settings → Billing. Verify that it displays the Shopify
subscription and that Manage in Shopify opens Shopify-hosted pricing.
4. Select a development-store plan only when Shopify confirms there will
be no charge. Return to billing and verify the authoritative plan,
state, renewal date, and fixed account limits.
5. Confirm ordinary accounts continue using Stripe checkout and billing.
A Shopify account must not enter Stripe subscription or top-up actions.
6. Verify signed uninstall cleanup clears store credentials and removes
Shopify entitlements, including while the integration flag is off.

The full first-install, signup, confirmation, and reinstall/reconnect
test belongs to #13550 after this infrastructure is merged.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-10 12:28:24 +04:00
Sojan Jose
50a812e8d0 feat(inboxes): Improve channel finish setup guidance (#13539)
## Summary
This change removes the confusing manual setup copy for Twilio webhook
configuration when finish setup can already be handled automatically,
and aligns the finish-setup user experience so onboarding is consistent
across SMS, WhatsApp, and Bandwidth channels.

## What changed
- Update the Twilio/SMS Finish Setup screen so Twilio-related channels
that are already auto-configured do not require users to manually edit
callback URLs.
- Keep manual guidance only where Twilio channels still require user
action (WhatsApp-specific onboarding path).
- Ensure Bandwidth retains clear guidance only where the callback URL
still must be configured by the user.
- Persist any webhook changes needed for WhatsApp flow directly during
channel save so users are no longer blocked by an extra external config
step.

## How to test
- Open each channel setup flow and validate finish-setup messaging/next
actions.
- Confirm WhatsApp still shows the expected setup step when webhook
auto-setup is not available.
- Verify Bandwidth still shows callback-URL guidance.

## Screenshots
- Twilio SMS:
<img width="1280" height="720" alt="twilio-sms-finish-final"
src="https://github.com/user-attachments/assets/2d22ae5f-1d50-42f2-9049-241340d40bb1"
/>


- Twilio WhatsApp:
<img width="1280" height="720" alt="twilio-whatsapp-finish-final"
src="https://github.com/user-attachments/assets/a85bec5a-5e23-4cbe-88f4-1f104fbdc8a1"
/>


- Bandwidth SMS:
<img width="1280" height="720" alt="bandwidth-sms-finish-final"
src="https://github.com/user-attachments/assets/59b9898f-e643-4dab-91d4-7b01c125a123"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-10 12:08:58 +04:00
Augusto Xavier
9d8d46a3af fix(attachment): resolve inline audio URL via configured Active Storage route (#15060)
## Description
`Attachment#inline_audio_url` hardcoded `rails_storage_redirect_url`, so
audio playback ignored `config.active_storage.resolve_model_to_route`.
On S3 setups configured with `:rails_storage_proxy` (required to avoid
CORS/ORB failures) audio URLs still pointed directly at S3, and Chrome
blocked the cross-origin range request with `ERR_BLOCKED_BY_ORB`, so
playback failed. This switches the method to `url_for(file)` — the same
helper `file_url` already uses — so the configured Active Storage route
is honoured. Fixes #15006.

### Root cause
`app/models/attachment.rb:118-122` called
`Rails.application.routes.url_helpers.rails_storage_redirect_url(file,
disposition: 'inline')`, which always builds a redirect URL regardless
of `resolve_model_to_route`. `file_url` (`:57`) uses `url_for(file)` and
respects the setting — audio was the only inconsistency.

### Solution
Use `url_for(file)` in `inline_audio_url`, mirroring `file_url`.
- Default installs (`:rails_storage_redirect`) are unchanged — they keep
redirect URLs, preserving the audio-on-redirect behavior introduced in
#14440 (which keeps large recordings off the proxy range-limiter).
- Only installs that explicitly set `:rails_storage_proxy` now serve
audio through the proxy — exactly what those admins configured, and
consistent with all other media via `file_url`.
- Inline playback is preserved by the `content_types_allowed_inline`
allowlist in `config/initializers/active_storage.rb`, so dropping the
explicit `disposition: 'inline'` has no effect on playback.
- The `ActiveStorageProxyRangeLimit` mitigation from #14440 is untouched
and still applies to any proxy traffic.

Note: the issue also mentions opus S3 object-metadata handling (Meta
error 131053). That is a separate, larger change and is intentionally
out of scope here.

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

## How Has This Been Tested?
- Added `spec/models/attachment_spec.rb` example asserting the audio
`data_url` honours `resolve_model_to_route` when the proxy route is
configured; it fails without the fix (URL was `.../blobs/redirect/...`)
and passes after.
- `bundle exec rspec spec/models/attachment_spec.rb` ✅ (27 examples, 0
failures)
- `bundle exec rubocop --parallel` ✅
- Manual QA: N/A — reproducing requires an S3 backend with
`resolve_model_to_route = :rails_storage_proxy`; behavior is covered by
the spec.

## 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 my code in hard-to-understand areas (where
needed)
- [ ] I have made corresponding changes to the documentation (N/A —
product docs are separate)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] Targets the `develop` branch

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-09-10 12:14:56 +08:00
Sojan Jose
6efffc895e fix: reject invalid conversation parameters (#15623)
Conversation priority mutations and filters now reject malformed request
values through Chatwoot's existing `422 Unprocessable Entity` paths
instead of allowing enum, bind, or database errors to reach Sentry.

## Closes

- [Sentry 6625387934](https://chatwoot-p3.sentry.io/issues/6625387934/)
- [Sentry 7288018570](https://chatwoot-p3.sentry.io/issues/7288018570/)
- [Sentry 7664169339](https://chatwoot-p3.sentry.io/issues/7664169339/)

## How to reproduce

- Send the unsupported `priority: "none"` value to a conversation
priority endpoint. It previously reached the enum assignment and raised
`ArgumentError`.
- Submit a conversation filter whose `values` contains an object. It
previously reached Active Record as a structured bind value and raised
`TypeError`.

## What changed

- Route priority updates through the existing strong-parameter method
and reject values outside the conversation priority enum.
- Remove the unsupported `none` value from the public priority API
schemas.
- Reject structured filter values in the shared filter-condition
validator while preserving scalar and scalar-array values.
- Document the request-boundary validation rule in `AGENTS.md`.
2026-09-09 10:51:22 -07:00
Muhsin Keloth
4114ff1f9e feat(whatsapp): grant embedded signup access after use case submission (#15749)
Customers can request WhatsApp Embedded Signup access by submitting
their business use case in the dashboard. Access is enabled immediately
and preserved across billing changes.

#### How to test
On a Cloud account without Embedded Signup access, open WhatsApp inbox
setup, click **Request access**, and submit a use case. The signup form
should appear immediately, and access should remain enabled after
refreshing.

<img width="1596" height="1456" alt="CleanShot 2026-09-09 at 12 14
55@2x"
src="https://github.com/user-attachments/assets/a4ea5824-a4ec-48fc-9bab-b7858386fb78"
/>

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-09 17:58:45 +04:00
izcoser
5dc47d2def feat: support whatsapp input select row descriptions (#14938)
## Description

Fixes #14911

Adds optional `description` support for `input_select` items and
forwards it when WhatsApp providers build interactive list rows. This
lets integrations use compact row titles with longer explanatory
subtitles in WhatsApp list messages while keeping existing
`input_select` payloads backward compatible.

I kept this as an optional passthrough and did not add WhatsApp-specific
length validation in the generic content attribute validator, matching
the existing behavior for row titles.

## Type of change

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

## How Has This Been Tested?

Ran the focused lint and specs in the Chatwoot devcontainer image
against current `origin/develop` after rebasing:

```bash
bundle exec rubocop app/models/concerns/content_attribute_validator.rb app/services/whatsapp/providers/base_service.rb spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb spec/services/whatsapp/providers/whatsapp360_dialog_service_spec.rb
bundle exec rspec spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb spec/services/whatsapp/providers/whatsapp360_dialog_service_spec.rb spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
```

Result: RuboCop reported no offenses; RSpec finished with 49 examples, 0
failures. Also ran `bundle exec rake swagger:build` after updating the
OpenAPI source.

## 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-09 17:54:40 +04:00
Aakash Bakhle
b88b8ab6ad fix: report filters resetting when opening a team (#15703)
Opening a team from Reports > Teams now keeps the selected date range
and Business Hours setting, so the detail cards use the overview
filters. The shared link applies the same behavior to agent, inbox, and
label reports.

The link preserves the current route query, matching the existing report
entity selector. A regression test clicks each report link and checks
that report filters and unrelated query parameters survive navigation.

Supersedes #14202 for the reproduced filter reset. The supplied
reproduction showed matching aggregates after manually restoring
identical filters. Local verification reproduced the dropped query and
confirmed the navigation fix; the database-backed browser comparison was
not repeated in this task.

## Closes

Fixes #14141

## How to reproduce

1. Open Reports > Teams, select Last 30 days, and enable Business Hours.
2. Click a team in the overview table.
3. Confirm the detail page retains Last 30 days and Business Hours
instead of resetting to Last 7 days with Business Hours off.
2026-09-09 16:01:48 +05:30
Muhsin Keloth
2c82030638 docs(campaigns): document campaign creation and scheduling APIs (#15748)
Customers can already create and schedule campaigns through the API, but
these endpoints are missing from the public API reference. This adds
campaign documentation to the Application API so integrators can
discover the existing functionality and use it without relying on
dashboard requests.

Documents listing, creating, retrieving, updating, and deleting
campaigns, with request and response schemas and a scheduled campaign
example. Includes administrator permissions, account API access
requirements, account-scoped campaign IDs, and the distinction between
website triggers and scheduled one-off campaigns.

### Things to know

- Documentation only; runtime behavior is unchanged.
- Requests accept an ISO 8601 scheduling timestamp; responses return
Unix seconds.
- Existing channel-specific availability and campaign requirements still
apply.
- Regenerated the combined and grouped OpenAPI files. All five documents
pass OpenAPI validation, and campaign references resolve.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-09 13:19:38 +04:00
Aakash Bakhle
4a1499f1f7 fix: enable reply suggestion search for self-hosted Premium (#15727)
## Summary

- Enable documentation search for reply suggestions on self-hosted
Premium installations.
- Keep the existing Cloud and self-hosted Enterprise behavior.
- Add a focused regression test for the Premium path.

## Why

Self-hosted Premium includes Captain and embeddings, but the reply
suggestion search gate only allowed Cloud and self-hosted Enterprise.
Agents on Premium received suggestions without FAQ search.

## Tests

- `bundle exec rspec spec/lib/captain/reply_suggestion_service_spec.rb`
- `bundle exec rubocop
enterprise/lib/enterprise/captain/reply_suggestion_service.rb
spec/lib/captain/reply_suggestion_service_spec.rb`
- `git diff --check`

Linear:
https://linear.app/chatwoot/issue/AI-211/enable-search-documents-in-reply-suggestions-for-self-hosted-premium
2026-09-09 17:10:19 +08:00
Aakash Bakhle
63c0ac7965 fix: generate Captain FAQ suggestions in account locale (#15728)
## Description

FAQ suggestions currently use the conversation language, so an English
account can receive Arabic suggestions. Generate FAQ questions and
answers in the account language regardless of the source conversation
language.

Use the account locale for the generation prompt, saved suggestion and
observation language, matching scopes, and job lock. Keep the language
columns and existing base-language normalization. If the account locale
changes, new FAQs use the new language and existing records retain their
original language.

Language selection reads the account locale directly. No language
classifier or extra LLM call is added.

Fixes
[CW-7568](https://linear.app/chatwoot/issue/CW-7568/suggestions-to-be-made-in-account-locale).

## Type of change

- [x] Bug fix

## How has this been tested?

- FAQ service and job specs: 34 examples, 0 failures in an isolated Cook
test database, with mocked LLM responses.
- RuboCop passed for all three changed files.
- The final tree exactly matches the previously tested account-locale
change after reverting the CLD3 follow-up.
- `git diff --check` passed.

## 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 my fix is effective
- [x] New and existing focused unit tests pass locally
2026-09-09 17:08:19 +08:00
Muhsin Keloth
27c87bf0e3 fix(whatsapp): skip tracking-only webhook jobs (#15706)
WhatsApp tracking callbacks currently create background jobs even though
Chatwoot does not consume them. This change acknowledges payloads
containing only `tracking_events` after signature verification and the
inactive-number check, then skips enqueueing them to reduce unnecessary
webhook processing.

Normal message-status updates, incoming messages, WhatsApp Business app
echoes, calls, and mixed payloads retain their existing processing.

### Closes

Closes
https://linear.app/chatwoot/issue/CW-8124/skip-unused-whatsapp-tracking-events-before-enqueueing

### How to test

- Send a validly signed tracking-only webhook to an active test inbox.
Expect HTTP 200 and no WhatsApp event job.
- Send a validly signed tracking-only webhook for a number in
`INACTIVE_WHATSAPP_NUMBERS`. Expect HTTP 422 and no job.
- Send the same payload with an invalid signature to an inbox that
requires signature verification. Expect HTTP 401 and no job.
- Send and receive a normal WhatsApp message. Confirm incoming messages
appear and delivery/read indicators continue to update.
- Replay a payload containing both tracking events and a normal message
event. Confirm it is still enqueued through the existing path.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-09 13:05:13 +04:00
Sony Mathew
5a53b16f14 test: cover WhatsApp HTML-like outbound payload (#15750)
## Description

Adds provider-level regression coverage confirming that HTML-like
outbound content reaches the WhatsApp Cloud API request unchanged
instead of becoming an empty message.

This is a focused follow-up to #14192.

## Closes

Regression coverage for #13493, which was fixed by #14192.

## Type of change

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

## How Has This Been Tested?

The new provider-level example passes against current develop and fails
against the pre-fix parent, where the request contained an empty text
body. The focused WhatsApp provider and markdown renderer suites 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-09-09 14:22:18 +05:30
Sony Mathew
961d97ef76 feat(inboxes): persist social provider names (#15629)
Instagram and TikTok inboxes now keep the provider username on the
channel, while Facebook inboxes keep the provider Page name.
Reauthorizing a connection refreshes this provider-owned value without
overwriting the inbox alias chosen by an administrator.

## Closes

Closes
[CW-8060](https://linear.app/chatwoot/issue/CW-8060/persist-provider-names-for-social-inbox-channels).

Related identifier work:
[CW-7245](https://linear.app/chatwoot/issue/CW-7245/show-phone-numberchannel-identifier-consistently-in-inbox-ui).

Implementation plan: [CW-8060 provider-name
plan](https://linear.app/chatwoot/document/cw-8060-persist-provider-names-for-social-inbox-channels-5d0dbd35814c).

## What changed

- Add a nullable provider_name column to Instagram, TikTok, and Facebook
Page channel tables.
- Persist provider usernames or Page names during connection and refresh
them during reauthorization.
- Preserve customized inbox names during Instagram and TikTok
reauthorization.
- Expose provider_name as a read-only inbox API attribute and update the
generated Swagger contracts.
- Add a reusable, sequential backfill service plus the
social_channels:backfill_provider_names Rake task with dry-run, account,
provider, delay, retry, and provider-scoped resumable batch controls.
- Validate all numeric backfill controls strictly so malformed values
fail before provider requests.
- Remove Facebook callback diagnostics that logged access tokens.

## How to test

1. Connect Instagram, TikTok, and Facebook Page inboxes and confirm the
channel provider_name contains the provider username or Page name.
2. Rename an inbox, reauthorize it, and confirm provider_name refreshes
while the inbox name stays unchanged.
3. Fetch the inbox API and confirm provider_name is returned for these
three channel types.
4. Preview the backfill with bundle exec rake
social_channels:backfill_provider_names.
5. Run a limited write batch with DRY_RUN=false PROVIDER=tiktok
LIMIT=100 and optional ACCOUNT_ID and DELAY_SECONDS values.
6. Continue the same provider with AFTER_ID set to the prior summary's
last_attempted_id. The remaining count includes earlier failed or
skipped records so they can be retried separately.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-09-09 12:36:43 +04:00
Lucas
5bd167d31c fix: preserve HTML-like outbound message content (#14192)
Preserves messages containing raw HTML-like text instead of silently
dropping their contents before channel delivery. Telegram escapes the
literal tags because its provider payload uses HTML parse mode, while
the other text-based channels retain the original content.

## Closes

Closes #13493

## How to reproduce

1. Send `<a>\n<b></b></a>asdf` through a WhatsApp or another text-based
inbox.
2. Without this change, the outbound markdown renderer returns an empty
string and the message cannot be delivered.
3. With this change, the original content is retained. Telegram displays
the same literal text through an HTML-safe payload.

## What changed

- Render CommonMark HTML block and inline HTML nodes from their stored
string content.
- Escape those nodes specifically in the Telegram renderer to preserve
literal text without treating it as provider markup.
- Add exact regression coverage for all affected channel mappings,
tags-only content, trailing content, inline HTML, Twilio WhatsApp
routing, and the final Telegram request payload.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-09-09 16:14:27 +08:00
Petterson
ed42e09bed fix(widget): prevent duplicate email-collect messages (#14855)
## Pull Request Description

The email-collect web-widget template can run twice for the same
conversation and create a duplicate pair of messages (the "Give the team
a way to reach you." line + the email-input box appear twice in the
widget).

`MessageTemplates::HookExecutionService#should_send_email_collect?`
guards only with `email_collect_was_sent?` (does an `input_email`
message already exist?), checked outside any lock. When two hook
executions race for the same conversation — e.g. two incoming widget
messages in quick succession — both pass the guard before either
commits, so each creates the pair. Because the two messages are created
in one transaction, the within-run after_create_commit re-trigger is
already safe; the gap is the lack of serialization across separate
executions.

This:
- Serializes creation in
`MessageTemplates::Template::EmailCollect#perform` with
`conversation.with_lock` and re-checks inside the lock, so concurrent
executions can't both create the pair. (`reload` first because the
conversation passed in from the message hook can carry an unpersisted
`display_id` change, which Rails refuses to row-lock.)
- Adds `return false unless message.incoming?` to
`should_send_email_collect?`, so the hook only reacts to the contact's
incoming message and never to the template messages it creates —
consistent with `should_send_out_of_office_message?`.

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

## How Has This Been Tested?
- `spec/services/message_templates/template/email_collect_spec.rb` —
added a test that invoking the template twice for the same conversation
creates only one pair.
- Existing `hook_execution_service` specs (OSS + enterprise) pass.

## 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: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-09-09 16:09:24 +08:00
Mali Tshabalala
545b82f01d fix: Brazil whatsapp duplicate contacts (#14264)
# Pull Request Template

## Description

Fixes #13986

This PR fixes duplicate WhatsApp contacts created for Brazilian and
Argentine phone number variants.

When WhatsApp Coexistence receives device-initiated or echo messages,
the same customer can arrive with different valid number formats. For
example, Brazil numbers may arrive with or without the extra mobile 9,
and Argentina numbers may arrive with or without the extra 9 after
country code 54.

Previously, Whatsapp::PhoneNumberNormalizationService checked only one
normalized number format. If an existing contact_inbox was stored using
the alternate format, Chatwoot did not find it and created a duplicate
contact and conversation.

This change updates the normalization lookup to:

- Check the incoming number first.
- Check the normalized number.
- Check Brazil-specific alternate formats.
- Check Argentina-specific alternate formats.
- Reuse the existing contact_inbox if any candidate matches.
- Fall back to the original incoming number if no existing contact is
found.

This preserves existing behavior for new contacts while preventing
duplicate contacts for known WhatsApp number variants.

## Type of change

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


## How Has This Been Tested?

Tested using the affected WhatsApp and Twilio inbound message specs.

Run:
bundle exec rspec spec/services/twilio/incoming_message_service_spec.rb
bundle exec rspec
spec/services/whatsapp/incoming_message_service_spec.rb

Verified scenarios include:

Brazilian WhatsApp numbers with and without the ninth digit.
Argentine WhatsApp numbers with and without the extra 9 after country
code.
Existing contact inbox is reused when an alternate number format already
exists.
New contact inbox is created using the original incoming number when no
existing contact is found.

## 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: malit <malit@mit.edu>
Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-09-09 13:28:25 +05:30
Vishnu Narayanan
f854cedced feat: mask audit log IP and show geo location behind a flag (#15455)
## Description

Audit log entries expose the raw client IP address in plaintext to
everyone with audit-log access. This shows a derived geo location (city,
country) by default and keeps the raw IP hidden unless an account
explicitly opts in.

- The audit log API now returns a derived `location` (city, country),
resolved from the stored IP via the existing GeoIP lookup in a
background job, so no request is blocked on the lookup.
- When a location cannot be resolved, a partially masked IP (e.g.
`203.0.113.x`) is returned as a fallback. The full IP is never sent to
the client unless the new per-account `audit_log_ip_address` feature
flag is enabled.
- The raw IP is still stored on the record; only what the API renders
changes.

Note: location requires the GeoIP database to be provisioned; without
it, the masked-IP fallback is shown.

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

## Type of change

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

## How Has This Been Tested?

Added model, job, and request specs covering masking (IPv4, IPv6 incl.
compressed, malformed, nil), background resolution, and flag on/off API
behaviour. Existing audit-log and account specs pass.

## 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 feature works
- [x] New and existing unit tests pass locally with my changes
2026-09-09 14:28:01 +08:00
Tanmay Deep Sharma
7e0f38be88 feat(billing): cancel subscriptions at period end and show the cancellation date (#15666)
The billing page now shows when a subscription is set to end. After a
customer cancels from the Stripe portal, the plan card shows "Cancels on
<date>" instead of "Renews on", and goes back to "Renews on" if they
resume.

## Closes

-
[CW-8034](https://linear.app/chatwoot/issue/CW-8034/mark-the-subscription-as-cancelled-at-the-end-of-the-period)

## How to test

1. On a cloud account with a paid plan, cancel the subscription from the
billing portal.
2. Settings → Billing shows "Cancels on <date>", and the plan stays
active until that date.
3. Resume the subscription, or change the seat count, in the portal —
the card goes back to "Renews on".

## What changed

- `subscription_cancels_on` is read from Stripe's `cancel_at` (falling
back to the period end for older subscriptions that only set
`cancel_at_period_end`), stored on the account and exposed in the
account payload.
- Account deletion now schedules the cancellation with an explicit
`cancel_at` timestamp. `cancel_at_period_end` is deprecated and is not
used by Stripe's flexible billing mode, where it stays `false` even
while a cancellation is scheduled.
2026-09-09 09:40:39 +05:30
Muhsin Keloth
b227f80427 feat(whatsapp): route BSUID calls through the active identity (#15546)
WhatsApp customers can become addressable only by a BSUID after WhatsApp
stops sharing their phone number. Text messages already follow the exact
identity attached to the conversation, but outbound calling still
assumes that the contact has a phone number. This hides or rejects the
call action for BSUID-only customers and can select the wrong
destination after contacts are merged.

This PR makes the current conversation's WhatsApp identity authoritative
for outbound calls. Calls and call-permission requests use the exact
`ContactInbox#source_id` attached to that conversation. Phone-number
calling continues to work as before.

### What changed

- Show the call action for a BSUID-only conversation when its WhatsApp
inbox has calling enabled.
- Send the BSUID in Meta's `recipient` field for outbound calls and
call-permission requests.
- Keep phone-number recipients on the existing `to` field.
- Preserve the selected conversation instead of guessing between
identities owned by a merged contact.
- Keep contact-level calling phone-only when no conversation has
established the destination.

### Things to know

- This change applies to Meta Cloud WhatsApp calling. Twilio Voice does
not use the WhatsApp BSUID calling API and remains unchanged.
- This PR is based on
[#15175](https://github.com/chatwoot/chatwoot/pull/15175). Once that PR
is merged, this PR should be retargeted to `develop`.

### How to test

1. Use a Meta Cloud WhatsApp inbox with calling enabled.
2. Open a conversation whose `ContactInbox#source_id` is a BSUID and
whose contact has no phone number.
3. Confirm the call action is available and start an outbound call.
4. Confirm the call remains in the same conversation and Meta receives
the BSUID as `recipient`.
5. Test a customer without call permission and confirm the permission
request is sent to the same BSUID identity.
6. Confirm a normal phone-number conversation still places the call
using the existing flow.

### Fixes

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

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-09-08 17:29:47 +04:00
Vaibhav Srivastava
dc0748b220 docs: fix typo in message provider JSDoc (#15616)
Corrects `differnt` to `different` in the `shouldGroupWithNext` JSDoc
description in
`app/javascript/dashboard/components-next/message/provider.js`.

Comment-only change; no runtime behavior changes.

Signed-off-by: Vaibhav Srivastava <vaibhavsri1712@gmail.com>
2026-09-08 06:26:15 -07:00
Muhsin Keloth
8ecae519f1 fix(widget): preserve spacing after message lists (#15717) 2026-09-08 18:32:29 +05:30
Sivin Varghese
720ef3f6e9 fix: stray backslash and vanishing signature separator in replies (#15639)
# Pull Request Template

## Description

This PR fixes stray `\` characters and missing `--` signature separators
when replies contain empty lines above the email signature. Empty lines
and Shift+Enter line breaks are now preserved as composed across
delivered emails, dashboard bubbles, text channels, and webhook
payloads.

### https://github.com/chatwoot/prosemirror-schema/pull/59

### What changed

* Bump `@chatwoot/prosemirror-schema` to 1.4.5, which fixes markdown
serialization of empty lines, hard breaks, and setext underline escaping
at the source.
* Strip the escaped signature delimiter cleanly when toggling or
removing the signature, so it never leaves a trailing `\`.
* Unescape the serializer's underline escape (`\--` → `--`) in
`WebhookContentNormalizer`, so API-channel and webhook consumers receive
the original text.
* Add end-to-end rendering specs covering every editor output shape with
23 byte-exact fixtures across cmark, both email modes, and all channel
renderers.

Fixes
https://linear.app/chatwoot/issue/CW-8062/email-replies-show-a-stray-backslash-instead-of-the-signature

## Type of change

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

## How Has This Been Tested?

### How to reproduce

On an email inbox with a message signature enabled, type a reply and
press Enter at the end, leaving an empty line above the `--` separator,
then send. The delivered email and email bubble show a stray `\` where
the empty line should be, and the `--` separator disappears.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-08 15:03:45 +05:30
Sivin Varghese
6543e47c5e fix: mirror directional widget icons in RTL locales (#15714)
# Pull Request Template

## Description

This PR fixes directional widget icons not mirroring correctly in RTL
locales. The relevant chevrons and send icon now use the correct
direction when the widget is rendered in RTL, while LTR rendering
remains unchanged.

Fixes
[CW-8120](https://linear.app/chatwoot/issue/CW-8120/website-widget-directional-icons-back-chevron-send-button-csat-arrow),
https://github.com/chatwoot/chatwoot/issues/15696

## Type of change

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

## How Has This Been Tested?

### Screencast

**Before**


https://github.com/user-attachments/assets/1c95b438-136d-46e0-a346-d3cd4ce0ce5c



**After**


https://github.com/user-attachments/assets/85c2870e-34ef-49d5-8fab-95b728736b6c




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-08 15:00:25 +05:30
Shivam Mishra
89fac8909a fix: prevent stale messages when switching conversations (#15699)
Fixes a frontend race that can leave messages from a previously opened
conversation visible in the current conversation until the page is
refreshed.

Switching A → B → A before A’s history finishes loading can request the
same page twice. Both responses prepend the same messages. If reconnect
sync later sorts those duplicates, Vue can leave stale bubbles behind
while reconciling duplicate message keys.

History loads now share pending requests for the same account,
conversation, and pagination cursors. History merges skip existing
message IDs, preserving newer realtime updates, and failed loads remain
eligible for retry. Regression specs cover rapid switching, response
isolation, failure and retry, pagination, and overlapping history.

## How to reproduce

1. Choose two conversations, A and B, with distinct messages and enough
history to scroll.
2. Open browser DevTools and set Network throttling to **Slow 3G**.
3. Reload the page to clear the frontend conversation cache.
4. Open A, switch to B, then return to A **before A’s message history
finishes loading**.
5. Wait for the requests to finish. Repeat from step 3 if needed, since
this depends on timing.
6. Briefly disconnect and reconnect your network to trigger reconnect
sync, then switch between A and B.

**Before the fix:** Duplicate messages may appear, and reconnect sync
can leave stale message bubbles from another conversation visible until
a refresh.

**Expected after the fix:** Each conversation shows only its own
messages, without duplicates. Scrolling up continues to load older
messages correctly.
2026-09-08 14:51:01 +05:30
Shivam Mishra
6148b3f9fc feat: allow rotating the inbox HMAC token (#15715)
Admins can now rotate the identity validation secret of a Website or API
inbox from inbox settings, instead of having to recreate the inbox. The
key is masked by default with show and copy controls, and rotating asks
for confirmation since the old key is revoked immediately.

## Masked key

### Website Channel

<img width="2296" height="336" alt="CleanShot 2026-09-08 at 12 31 53@2x"
src="https://github.com/user-attachments/assets/7a1b87fb-d5c9-419e-9eb2-5e9837395873"
/>

### API Channel

<img width="1810" height="554" alt="CleanShot 2026-09-08 at 12 32 16@2x"
src="https://github.com/user-attachments/assets/5c97cf6f-1959-4606-82f4-38bffcbea447"
/>

## Rotate warning

<img width="3022" height="1720" alt="CleanShot 2026-09-08 at 12 12
23@2x"
src="https://github.com/user-attachments/assets/3898ae7a-8a11-4bb2-9179-f203aa3db739"
/>
2026-09-08 14:47:28 +05:30
Shivam Mishra
7f7ae95268 fix(auth): show OAuth error toast after snackbar mounts (#15716)
Personal email signups through Google now show the existing error toast
when redirected back to login. The message remains visible for six
seconds.

The login page previously emitted the toast before the snackbar
subscribed to events. Emit it after mounting and the next Vue tick so
the snackbar receives it.

## How to reproduce

1. Open `/app/login?error=business-account-only` while signed out, or
attempt Google signup with a new personal email on an installation that
requires work emails.
2. Confirm the work-email error toast appears for six seconds.
3. Confirm the `error` query parameter is removed after the toast is
triggered.
2026-09-08 12:56:28 +04:00
Sony Mathew
f9b7715776 feat: improve Captain playground test setup (#15652)
Adds a session-only test setup panel to the Captain V2 playground so
teams can test saved or temporary scenarios, response guidelines,
guardrails, and pasted knowledge without changing assistant
configuration. Temporary items can be promoted individually by
administrators, and each response exposes safe run details for scenario
handoffs and tool calls.

## Closes

-
https://linear.app/chatwoot/issue/CW-8057/make-the-captain-playground-better

## What changed

- Adds a responsive, tabbed test setup panel with saved items collapsed
by default.
- Supports saved and disabled scenario selection, temporary scenarios
and rules, and up to 10,000 characters of pasted knowledge.
- Lets administrators save temporary scenarios, guidelines, and
guardrails individually, and save pasted knowledge as a Markdown
document.
- Applies runtime-only prompt and agent overrides and returns sanitized
tool and handoff run details.
- Keeps Captain V1 and production agent flows unchanged; customer and
conversation targeting are not included in this version.

## How to test

1. Open a Captain V2 assistant playground and confirm saved scenarios,
guidelines, guardrails, and knowledge counts load after refresh.
2. Add or exclude temporary and saved items, send messages, and inspect
run details.
3. As an administrator, promote one temporary item and confirm it
remains included without clearing the chat.
4. Save pasted knowledge permanently and confirm a Markdown document is
created for the assistant.
5. Reset test setup and confirm temporary content and messages clear
while the latest saved configuration is restored.
6. Check the panel on a narrow viewport and confirm the V1 playground
remains unchanged.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-09-08 16:19:12 +08:00
Klaus Alusa
87007a8499 fix(instagram): display button postbacks as incoming messages (#14237)
Instagram button-click postbacks that reach Chatwoot now appear as
incoming messages in both Instagram Login and Facebook Login inboxes.
Previously, these events were acknowledged but skipped by the Instagram
webhook job.

Postbacks use the existing Instagram message flow, preserving their
external message IDs and reusing its contact lookup, duplicate
detection, authorization handling, and conversation selection. Event
types are evaluated per item so a batch containing a postback followed
by a regular message or read event is routed correctly.

### Closes

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

### Things to know

- This handles received `postback` events. Webhook subscriptions and
updates to existing connected inboxes are outside this PR.
- Quick replies delivered as `message.quick_reply` continue through the
normal message path.
- This does not establish the cause of
https://github.com/chatwoot/chatwoot/issues/14584, which reports missing
webhook delivery.

### How to test

1. With Instagram postback delivery configured, click an icebreaker or
template button for an Instagram Login inbox. Confirm the selected title
appears as an incoming message.
2. Repeat for an Instagram inbox connected through Facebook Login.
3. Replay the same event. Confirm that only one message exists for its
message ID.
4. For a known contact, make profile lookup unavailable and send another
postback. Confirm the message still appears.
5. Enable the single-conversation setting, resolve the conversation, and
send a new postback. Confirm the existing conversation is reused.
6. Deliver a batch containing a postback and a normal DM. Confirm each
retains its own message text.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-08 12:06:20 +04:00
chinatsu1124
c9f1867369 fix: preserve line breaks in Copilot reply suggestions (#14765) 2026-09-07 22:58:44 +05:30
Muhsin Keloth
9253468b34 feat(whatsapp): route campaigns to BSUID recipients (#15614)
BSUID-only contacts can now be included in one-off WhatsApp campaigns
for Meta Cloud inboxes instead of being skipped because the contact has
no phone number. Campaign delivery resolves a unique BSUID for the
selected inbox, sends supported templates through Meta's `recipient`
contract, and keeps existing phone-number campaign delivery unchanged.

Fixes
https://linear.app/chatwoot/issue/CW-7802/support-bsuid-recipients-in-whatsapp-campaigns-and-bulk-sends

### Things to know

- This PR is intentionally scoped to Meta Cloud WhatsApp campaigns.
- Twilio WhatsApp campaign support is deferred; this PR does not change
Twilio campaign UI, template parsing, provider submission, or analytics.
- A phone number remains the preferred campaign destination when
present.
- Phone-less recipients require exactly one BSUID on the selected inbox.
Missing or ambiguous identities are skipped/failed per recipient instead
of selecting another merged identity.
- Authentication templates are blocked for BSUID-only campaign
recipients before provider submission.

### How to test

1. Create a one-off WhatsApp campaign using a Meta Cloud inbox and an
audience containing a contact with no phone number but exactly one BSUID
ContactInbox in that inbox.
2. Select an approved non-authentication template and start the
campaign.
3. Confirm the provider request addresses that contact by BSUID and the
recipient is not skipped for a missing phone number.
4. Add a phone number to the same contact and confirm the campaign still
sends to the phone number.
5. Add two BSUID ContactInboxes for the same contact and selected inbox;
confirm the recipient is not sent because Chatwoot refuses to choose an
ambiguous destination.
6. Select an authentication template for a BSUID-only recipient; confirm
Chatwoot blocks it before provider submission.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-07 19:13:37 +04:00
Shivam Mishra
aa3d5c3021 fix: refresh conversation lists safely on reconnect (#15651)
Refreshes the conversation list from page one after a WebSocket
reconnect while retaining the cached list if the refresh fails. It keeps
the active conversation up to date and prevents stale responses from
inactive or cached tabs from overwriting the current view.

## Related

This reconnect work was split from
https://github.com/chatwoot/chatwoot/pull/15523 so the shared tab-cache
changes can be reviewed separately.

## How to reproduce

1. Load enough conversations to populate multiple pages and open an
older conversation.
2. Disconnect the browser from the network, then update the active
conversation from another session.
3. Restore the network and switch to another cached assignee tab while
the reconnect request is pending.
4. Before this fix, the old response can overwrite the newly active tab,
the selected conversation metadata can remain stale, and the selected
item can break the server-provided list order.
5. After this fix, the active tab remains authoritative, the active
conversation metadata and messages refresh, and the page-one order is
preserved.
6. Make the reconnect request fail and verify the existing cached list
remains available.

## What changed

- Replaces the reconnect delta query with a fresh page-one request.
- Invalidates in-flight list requests whenever chat-list filters change,
including cached assignee-tab switches.
- Replaces cached conversations and resets pagination only after a
successful response.
- Preserves the server response order while merging the selected
conversation's local message state.
- Retains an active conversation outside page one and refreshes its
conversation metadata before syncing messages.
- Shares request freshness across standard, advanced-filter, and
saved-folder list requests.
- Clears the loading state when the current standard-list request fails.
2026-09-07 18:08:21 +05:30
Aakash Bakhle
8484f962ee fix: send Captain resolution message from resolve tool (#15668)
## Description

When Captain used the resolve conversation tool, it resolved the
conversation without sending the Assistant's configured resolution
message.

This change moves resolution message creation into a shared service and
calls it from both Captain resolution paths. The tool sends the
configured message before resolving the conversation. The inactivity job
keeps its existing translated fallback when no custom message is
configured.

Human initiated resolution is unchanged.

Fixes
[AI-206](https://linear.app/chatwoot/issue/AI-206/resolution-tool-doesnt-not-trigger-the-resolution-message)

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec
spec/enterprise/services/captain/conversation/resolution_message_service_spec.rb
spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb
spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb`
- 51 examples, 0 failures
- `bundle exec rubocop
enterprise/app/services/captain/conversation/resolution_message_service.rb
enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb
enterprise/lib/captain/tools/resolve_conversation_tool.rb
spec/enterprise/services/captain/conversation/resolution_message_service_spec.rb
spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb`
- 5 files inspected, no offenses detected

## 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
- [x] New and existing relevant unit tests pass locally with my changes
2026-09-07 15:39:22 +05:30
Muhsin Keloth
c44a98d879 feat(whatsapp): recover phone information for BSUID contacts (#15552)
BSUID-only WhatsApp contacts can now share their phone information
without agents having to ask manually or move the conversation away from
its active BSUID identity. Eligible WhatsApp Cloud conversations expose
a Request Contact Info action, track the request separately from message
delivery, and safely add a valid returned phone identity to the existing
contact.

The flow is limited to contacts whose active inbox identity is a BSUID
and who do not already have a phone number. Phone-backed conversations,
Twilio WhatsApp inboxes, and ordinary shared-contact messages keep their
existing behavior.

### Closes

Fixes
https://linear.app/chatwoot/issue/CW-7809/recover-phone-information-for-bsuid-only-whatsapp-contacts

Note: CW-7810 and CW-7811 were merged into CW-7809 as duplicate scope,
so response-processing and agent-UX requirements are now tracked under
CW-7809.

### Things to know

- This first delivery supports WhatsApp Cloud only. Twilio remains
unavailable until its request-and-response contract is verified end to
end.
- Inside the customer service window, Chatwoot sends Meta's interactive
contact-information request. Outside the window, agents can use an
approved template containing a `request_contact_info` button.
- A conflicting phone identity is surfaced as an identity conflict and
is never merged automatically.
- The generic Save Contact action is hidden only when the contact card
is the response to Chatwoot's contact-information request. It remains
available for ordinary shared contacts.

### What changed

- Added backend-controlled eligibility, delivery, and request-state
handling for BSUID-only WhatsApp Cloud conversations.
- Added safe processing for `origin: contact_request` responses while
preserving the active BSUID conversation and existing aliases.
- Added the agent action, template filtering, pending/shared/conflict
status display, and contact-panel refresh behavior.

### How to test

1. Open a WhatsApp Cloud conversation whose active contact inbox uses a
BSUID and whose contact has no phone number.
2. Select **Request Contact Info** and confirm that the outgoing message
shows **Waiting for contact information**.
3. In WhatsApp, select **Share Contact Info** and share the current
user's contact.
4. Confirm that Chatwoot adds the phone identity to the existing
contact, keeps the conversation on its BSUID contact inbox, and updates
the request state.
5. Confirm that the requested-response contact card does not show **Save
Contact**, while an ordinary shared-contact message still does.
6. Outside the customer service window, confirm that only approved
templates containing a Request Contact Info button are offered.

### Screenshots

**Chatwoot — BSUID-only contact and pending request**

<img width="2068" height="2086" alt="CleanShot 2026-08-21 at 14 04
43@2x"
src="https://github.com/user-attachments/assets/80a957cd-d8d5-489b-96a4-353a46af4ab3"
/>

**WhatsApp — Share Contact Info request and response**

<img width="1170" height="2532" alt="Screenshot 2026-08-21 at 15 05 00"
src="https://github.com/user-attachments/assets/a8feff39-d265-4658-8ac4-dcbb6b5858eb"
/>

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-07 14:02:12 +04:00
Muhsin Keloth
54c731b861 fix(whatsapp): classify BSUID contacts as leads (#15670)
WhatsApp customers who only expose a business-scoped user ID are
reachable in the inbox, but they should also be classified as CRM leads
once CRM v2 is enabled. This change promotes visitor contacts to leads
when WhatsApp identity sync sees a BSUID, so future CRM v2 contact lists
treat them like real reachable channel contacts.

The change keeps BSUIDs in ContactInbox aliases and does not write them
into contacts.identifier.

### Things to know

- This is scoped to WhatsApp BSUID identities, including Meta Cloud IDs
and Twilio's whatsapp:<BSUID> shape.
- This does not change the legacy resolved_contacts predicate when
crm_v2 is disabled.
- Existing BSUID-only contacts may still need a backfill before enabling
CRM v2 broadly.
- Plain phone source IDs and anonymous widget visitors are unaffected.

### How to test

1. Receive a WhatsApp message where the contact identity is a BSUID and
no phone number is available.
2. Confirm the contact remains attached to the WhatsApp ContactInbox
identity.
3. Confirm the contact is classified as a lead.
4. Enable CRM v2 for the account and confirm the contact appears in the
Contacts list.
5. Repeat with a Twilio WhatsApp BSUID payload and confirm the same lead
classification.
6. Confirm a phone-only WhatsApp source ID does not get promoted by this
BSUID-specific rule.

### What changed

- Promotes visitor contacts to leads when WhatsApp identifier sync sees
a BSUID source ID.
- Supports both native Cloud BSUIDs and Twilio-prefixed WhatsApp BSUIDs.
- Adds focused service coverage for CRM v2 visibility and non-BSUID
behavior.

Related:
https://linear.app/chatwoot/issue/CW-7982/whatsapp-contacts-identified-only-by-a-business-scoped-user-id-are
Related: https://github.com/chatwoot/chatwoot/issues/15530

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-07 14:01:07 +04:00
Shivam Mishra
1b3ae261df fix(automation): load SLA options before editing rules (#15661)
SLA policies could appear missing from existing automation rules after a
hard refresh. The policies were not deleted; the automation editor could
initialize before the account feature flags and SLA options finished
loading. This change reacts when SLA becomes available and waits for the
policy lookup before formatting an existing rule.

## How to reproduce

1. Use an account with SLA enabled and at least one SLA policy.
2. Create an automation rule with an Add SLA action and save it.
3. Open the automation settings page directly and hard refresh it.
4. Open the saved rule after the automation list appears.
5. Observe that the selected SLA can appear empty and the Add SLA
dropdown can show no results.

## What changed

- Load SLA policies when the account feature flag becomes available,
including after the page has mounted.
- Wait for the SLA policy request before formatting an existing
automation for editing.
2026-09-07 15:23:57 +05:30
Tanmay Deep Sharma
97bc9be1ee fix(twilio): register messaging webhook for voice inboxes and add webhook health tab (#15011)
When a Twilio number is set up through the Voice flow, Chatwoot
registers the voice webhooks but never the messaging one. Since a voice
channel is really an SMS channel with voice enabled, the number ends up
able to receive calls but not SMS, with no way to fix it from the
dashboard. This registers the messaging webhook on the voice path, and
adds a Health tab to Twilio inboxes that shows whether each webhook
Chatwoot expects is actually set on the number, with a button to
re-register them.

## How to reproduce

1. Create a Voice inbox with a Twilio number.
2. In the Twilio Console, open the number: the voice webhook points at
Chatwoot, but "A message comes in" is untouched.
3. Send an SMS to the number, nothing arrives in Chatwoot.

## How to test

1. Create a Voice inbox with a Twilio number, the messaging webhook is
now set automatically and inbound SMS lands in the inbox.
2. Open the inbox, go to Settings > Account Health. Each expected
webhook (messaging, plus voice, voice status and the TwiML app when
voice is on) shows as configured.
3. Point the messaging webhook somewhere else in Twilio and reload the
tab, it shows a mismatch with the reason and a hint. Click Register
webhooks and it goes back to green.
4. Point the inbox at a number that is not in the connected Twilio
account, the tab surfaces Twilio's own message instead of a blank card.

## Screenshots

A number whose voice webhooks drifted to an old tunnel — each webhook is
flagged with why Chatwoot cannot receive on it:


<img width="1120" height="900" alt="health-misconfigured"
src="https://github.com/user-attachments/assets/79af318c-918e-41eb-9210-3ca281a51e70"
/>


After clicking Register webhooks, all four are back on the current
installation URL and the repair button drops away:

<img width="1120" height="900" alt="health-healthy"
src="https://github.com/user-attachments/assets/e9d2cb9f-7978-413b-be1a-c70e0ed47214"
/>


When Twilio itself rejects the lookup, its message is shown rather than
an empty state:

<img width="1120" height="900" alt="health-error"
src="https://github.com/user-attachments/assets/c380185e-b1cc-431b-9c7e-5dc0dcddc6fe"
/>


## What changed

- The voice inbox creation path now registers the messaging webhook
alongside the voice ones.
- `Twilio::HealthService` reports the account, the sender and every
webhook Chatwoot depends on, with a reason per webhook (not set, URL
mismatch, wrong HTTP method, overridden by a trunk / TwiML app / the
number's own webhook, missing TwiML app).
- The health and register-webhook endpoints moved from a WhatsApp-only
concern to a shared `InboxHealthManagement` concern.
- `Twilio::WebhookSetupService` authenticates through
`Channel::TwilioSms#client`, so inboxes on API-key credentials can
repair their webhooks too.
- Inbound MMS media downloads use `api_key_secret` when the channel
stores one, so voice inboxes keep their attachments.
- The voice toggle saves on flip instead of waiting for a separate
Update click.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-09-07 10:24:22 +05:30
Sivin Varghese
008e7ca9ae feat: render media previews in the article diff panel (#15640) 2026-09-07 10:11:55 +05:30
dependabot[bot]
8314676d1b chore(deps-dev): bump @faker-js/faker from 9.9.0 to 10.5.0 in /tests/playwright (#15659)
Bumps [@faker-js/faker](https://github.com/faker-js/faker) from 9.9.0 to
10.5.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/faker-js/faker/releases">@​faker-js/faker's
releases</a>.</em></p>
<blockquote>
<h2>v10.5.0</h2>
<h2>What's Changed</h2>
<ul>
<li>docs: fix contribution git commands by <a
href="https://github.com/hiSandog"><code>@​hiSandog</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3770">faker-js/faker#3770</a></li>
<li>infra: trusted publishing via OIDC token by <a
href="https://github.com/Shinigami92"><code>@​Shinigami92</code></a> in
<a
href="https://redirect.github.com/faker-js/faker/pull/3743">faker-js/faker#3743</a></li>
<li>docs: add favicon by <a
href="https://github.com/ST-DDT"><code>@​ST-DDT</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/2853">faker-js/faker#2853</a></li>
<li>chore(deps): update eslint (major) by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/faker-js/faker/pull/3753">faker-js/faker#3753</a></li>
<li>infra: migrate to tsdown by <a
href="https://github.com/Shinigami92"><code>@​Shinigami92</code></a> in
<a
href="https://redirect.github.com/faker-js/faker/pull/3558">faker-js/faker#3558</a></li>
<li>chore(deps): update all non-major dependencies by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/faker-js/faker/pull/3776">faker-js/faker#3776</a></li>
<li>chore(deps): update codecov/codecov-action action to v6 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/faker-js/faker/pull/3777">faker-js/faker#3777</a></li>
<li>chore(deps): update dependency typescript-eslint to v8.57.2 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/faker-js/faker/pull/3778">faker-js/faker#3778</a></li>
<li>chore(deps): update vitest by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/faker-js/faker/pull/3779">faker-js/faker#3779</a></li>
<li>chore(deps): update dependency typescript-eslint to v8.58.0 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/faker-js/faker/pull/3780">faker-js/faker#3780</a></li>
<li>chore(deps): update devdependencies by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/faker-js/faker/pull/3781">faker-js/faker#3781</a></li>
<li>docs: instructions to solve v10 compatibility issues with jest by <a
href="https://github.com/pkuczynski"><code>@​pkuczynski</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3616">faker-js/faker#3616</a></li>
<li>infra: update cypress config by <a
href="https://github.com/Shinigami92"><code>@​Shinigami92</code></a> in
<a
href="https://redirect.github.com/faker-js/faker/pull/3782">faker-js/faker#3782</a></li>
<li>fix(locale): changed farsi phone locales to correct ones by <a
href="https://github.com/Javid-Izadfar"><code>@​Javid-Izadfar</code></a>
in <a
href="https://redirect.github.com/faker-js/faker/pull/3775">faker-js/faker#3775</a></li>
<li>feat(location): add postal address generation by <a
href="https://github.com/matthewmayer"><code>@​matthewmayer</code></a>
in <a
href="https://redirect.github.com/faker-js/faker/pull/3760">faker-js/faker#3760</a></li>
<li>feat(locale): add sl_SI locale, location module by <a
href="https://github.com/ZigaVukcevicDev"><code>@​ZigaVukcevicDev</code></a>
in <a
href="https://redirect.github.com/faker-js/faker/pull/3652">faker-js/faker#3652</a></li>
<li>fix(locale): phone numbers for en_AU by <a
href="https://github.com/hiSandog"><code>@​hiSandog</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3788">faker-js/faker#3788</a></li>
<li>feat(phone): mobile style for phone.number by <a
href="https://github.com/matthewmayer"><code>@​matthewmayer</code></a>
in <a
href="https://redirect.github.com/faker-js/faker/pull/3583">faker-js/faker#3583</a></li>
<li>fix(locale): typo in de_CH last name by <a
href="https://github.com/rbuetzer"><code>@​rbuetzer</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3799">faker-js/faker#3799</a></li>
<li>chore(deps): lock file maintenance by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/faker-js/faker/pull/3774">faker-js/faker#3774</a></li>
<li>feat(locale): add Farsi airline module locale by <a
href="https://github.com/Javid-Izadfar"><code>@​Javid-Izadfar</code></a>
in <a
href="https://redirect.github.com/faker-js/faker/pull/3789">faker-js/faker#3789</a></li>
<li>feat(date): add ability to provide year range for <code>past</code>
and <code>future</code> by <a
href="https://github.com/xDivisionByZerox"><code>@​xDivisionByZerox</code></a>
in <a
href="https://redirect.github.com/faker-js/faker/pull/3783">faker-js/faker#3783</a></li>
<li>feat(locale): add mn_MN_cyrl (Mongolian) locale by <a
href="https://github.com/btseee"><code>@​btseee</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3796">faker-js/faker#3796</a></li>
<li>feat: introduce FakerCore by <a
href="https://github.com/ST-DDT"><code>@​ST-DDT</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/2838">faker-js/faker#2838</a></li>
<li>docs: fix typo in internet.mac parameter by <a
href="https://github.com/ST-DDT"><code>@​ST-DDT</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3801">faker-js/faker#3801</a></li>
<li>chore: reformat person.bio by <a
href="https://github.com/ST-DDT"><code>@​ST-DDT</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3803">faker-js/faker#3803</a></li>
<li>fix(food): correct fruit and vegetable locale data by <a
href="https://github.com/hiSandog"><code>@​hiSandog</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3800">faker-js/faker#3800</a></li>
<li>feat(locale): improved en_IE postal_address by <a
href="https://github.com/matthewmayer"><code>@​matthewmayer</code></a>
in <a
href="https://redirect.github.com/faker-js/faker/pull/3797">faker-js/faker#3797</a></li>
<li>feat(locale): Add postal_address and improved secondary_address for
es by <a
href="https://github.com/matthewmayer"><code>@​matthewmayer</code></a>
in <a
href="https://redirect.github.com/faker-js/faker/pull/3798">faker-js/faker#3798</a></li>
<li>docs(image): remove deprecated urlLoremFlickr from module overview
by <a href="https://github.com/hiSandog"><code>@​hiSandog</code></a> in
<a
href="https://redirect.github.com/faker-js/faker/pull/3807">faker-js/faker#3807</a></li>
<li>fix: link release checklist in release PR workflow by <a
href="https://github.com/hiSandog"><code>@​hiSandog</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3802">faker-js/faker#3802</a></li>
<li>chore(deps): lock file maintenance by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/faker-js/faker/pull/3806">faker-js/faker#3806</a></li>
<li>chore(deps): update dependency sanitize-html to v2.17.3 [security]
by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/faker-js/faker/pull/3814">faker-js/faker#3814</a></li>
<li>feat(number): add distributor functions by <a
href="https://github.com/ST-DDT"><code>@​ST-DDT</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3375">faker-js/faker#3375</a></li>
<li>refactor(location): simplify locale access by <a
href="https://github.com/ST-DDT"><code>@​ST-DDT</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3809">faker-js/faker#3809</a></li>
<li>test(person): cleanup tests by <a
href="https://github.com/ST-DDT"><code>@​ST-DDT</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3804">faker-js/faker#3804</a></li>
<li>chore: import types from the source file directly by <a
href="https://github.com/ST-DDT"><code>@​ST-DDT</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3805">faker-js/faker#3805</a></li>
<li>docs(image): link to tracking issue by <a
href="https://github.com/ST-DDT"><code>@​ST-DDT</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3815">faker-js/faker#3815</a></li>
<li>docs: render multiple <a
href="https://github.com/example"><code>@​example</code></a> blocks as
tabbed code groups by <a
href="https://github.com/Shinigami92"><code>@​Shinigami92</code></a> in
<a
href="https://redirect.github.com/faker-js/faker/pull/3817">faker-js/faker#3817</a></li>
<li>feat(locale): Add more realistic Greek address data by <a
href="https://github.com/matthewmayer"><code>@​matthewmayer</code></a>
in <a
href="https://redirect.github.com/faker-js/faker/pull/3792">faker-js/faker#3792</a></li>
<li>fix(finance): routingNumber now uses real
FederalReserveRoutingSymbol from lookup table. by <a
href="https://github.com/woldaker"><code>@​woldaker</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3429">faker-js/faker#3429</a></li>
<li>feat(locale): add color to cy by <a
href="https://github.com/IestynGage"><code>@​IestynGage</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3816">faker-js/faker#3816</a></li>
<li>fix(locale): remove citation brackets from snake names by <a
href="https://github.com/AustralianRaven"><code>@​AustralianRaven</code></a>
in <a
href="https://redirect.github.com/faker-js/faker/pull/3822">faker-js/faker#3822</a></li>
<li>fix(helpers): ignore wrapper characters in fromRegExp by <a
href="https://github.com/ST-DDT"><code>@​ST-DDT</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3795">faker-js/faker#3795</a></li>
<li>refactor(core): expose core.locale as LocaleProxy by <a
href="https://github.com/ST-DDT"><code>@​ST-DDT</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3820">faker-js/faker#3820</a></li>
<li>docs: fix typos in jsdocs by <a
href="https://github.com/ST-DDT"><code>@​ST-DDT</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3823">faker-js/faker#3823</a></li>
<li>feat(locale): add Welsh (cy) sex definitions by <a
href="https://github.com/IestynGage"><code>@​IestynGage</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3824">faker-js/faker#3824</a></li>
<li>docs: change link in GitHub PR template by <a
href="https://github.com/IestynGage"><code>@​IestynGage</code></a> in <a
href="https://redirect.github.com/faker-js/faker/pull/3826">faker-js/faker#3826</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/faker-js/faker/blob/next/CHANGELOG.md">@​faker-js/faker's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/faker-js/faker/compare/v10.4.0...v10.5.0">10.5.0</a>
(2026-06-17)</h2>
<h3>New Locales</h3>
<ul>
<li><strong>locale:</strong> add color to cy (<a
href="https://redirect.github.com/faker-js/faker/issues/3816">#3816</a>)
(<a
href="5a6379f280">5a6379f</a>)</li>
<li><strong>locale:</strong> add Farsi airline module locale (<a
href="https://redirect.github.com/faker-js/faker/issues/3789">#3789</a>)
(<a
href="f4a1c3e51a">f4a1c3e</a>)</li>
<li><strong>locale:</strong> add mn_MN_cyrl (Mongolian) locale (<a
href="https://redirect.github.com/faker-js/faker/issues/3796">#3796</a>)
(<a
href="d17c0f1fe2">d17c0f1</a>)</li>
<li><strong>locale:</strong> Add more realistic Greek address data (<a
href="https://redirect.github.com/faker-js/faker/issues/3792">#3792</a>)
(<a
href="6153eb7768">6153eb7</a>)</li>
<li><strong>locale:</strong> Add postal_address and improved
secondary_address for es (<a
href="https://redirect.github.com/faker-js/faker/issues/3798">#3798</a>)
(<a
href="6c2a0abd30">6c2a0ab</a>)</li>
<li><strong>locale:</strong> add sl_SI locale, location module (<a
href="https://redirect.github.com/faker-js/faker/issues/3652">#3652</a>)
(<a
href="28d4f7cc28">28d4f7c</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) animal type definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3842">#3842</a>)
(<a
href="50a5ec9477">50a5ec9</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) book format definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3846">#3846</a>)
(<a
href="https://redirect.github.com/faker-js/faker/issues/3846">#3846</a>)
(<a
href="2b32d6ef02">2b32d6e</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) building numbers definition
(<a
href="https://redirect.github.com/faker-js/faker/issues/3892">#3892</a>)
(<a
href="4996a2b477">4996a2b</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) city name definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3884">#3884</a>)
(<a
href="ac36c222c5">ac36c22</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) continents definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3880">#3880</a>)
(<a
href="03a1682727">03a1682</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) county definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3849">#3849</a>)
(<a
href="f51d28a3c6">f51d28a</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) direction definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3840">#3840</a>)
(<a
href="d862071c60">d862071</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) internet domain suffix
definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3865">#3865</a>)
(<a
href="https://redirect.github.com/faker-js/faker/issues/3865">#3865</a>)
(<a
href="fb2e2cbe3d">fb2e2cb</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) phone number definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3878">#3878</a>)
(<a
href="70994dbf77">70994db</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) postal address definitions
(<a
href="https://redirect.github.com/faker-js/faker/issues/3850">#3850</a>)
(<a
href="e72fd7c0d7">e72fd7c</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) postcode definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3851">#3851</a>)
(<a
href="1afbd59cc1">1afbd59</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) sex definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3824">#3824</a>)
(<a
href="6f29eb7548">6f29eb7</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) state definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3891">#3891</a>)
(<a
href="https://redirect.github.com/faker-js/faker/issues/3891">#3891</a>)
(<a
href="606d47c1c5">606d47c</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) street name definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3889">#3889</a>)
(<a
href="143789efb3">143789e</a>)</li>
<li><strong>locale:</strong> add Welsh (cy) vehicle fuel definitions (<a
href="https://redirect.github.com/faker-js/faker/issues/3845">#3845</a>)
(<a
href="29c918a2be">29c918a</a>)</li>
<li><strong>locale:</strong> extend tr person last names (<a
href="https://redirect.github.com/faker-js/faker/issues/3839">#3839</a>)
(<a
href="f433771252">f433771</a>)</li>
<li><strong>locale:</strong> improved en_GB postcodes (<a
href="https://redirect.github.com/faker-js/faker/issues/3864">#3864</a>)
(<a
href="a4e503989a">a4e5039</a>)</li>
<li><strong>locale:</strong> improved en_IE postal_address (<a
href="https://redirect.github.com/faker-js/faker/issues/3797">#3797</a>)
(<a
href="fb3ffd373b">fb3ffd3</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>add first standalone module functions (<a
href="https://redirect.github.com/faker-js/faker/issues/3818">#3818</a>)
(<a
href="5d4f5ded06">5d4f5de</a>)</li>
<li><strong>date:</strong> add ability to provide date range for
<code>recent</code> and <code>soon</code> (<a
href="https://redirect.github.com/faker-js/faker/issues/3844">#3844</a>)
(<a
href="0bc038d5ca">0bc038d</a>)</li>
<li><strong>date:</strong> add ability to provide year range for
<code>past</code> and <code>future</code> (<a
href="https://redirect.github.com/faker-js/faker/issues/3783">#3783</a>)
(<a
href="237e7dc34a">237e7dc</a>)</li>
<li><strong>food:</strong> expand plant-based dish and pattern variety
(<a
href="https://redirect.github.com/faker-js/faker/issues/3887">#3887</a>)
(<a
href="5eaf729128">5eaf729</a>)</li>
<li>introduce FakerCore (<a
href="https://redirect.github.com/faker-js/faker/issues/2838">#2838</a>)
(<a
href="68e15d7e33">68e15d7</a>)</li>
<li><strong>location:</strong> add postal address generation (<a
href="https://redirect.github.com/faker-js/faker/issues/3760">#3760</a>)
(<a
href="753bec682d">753bec6</a>)</li>
<li><strong>number:</strong> add distributor functions (<a
href="https://redirect.github.com/faker-js/faker/issues/3375">#3375</a>)
(<a
href="325e402dfd">325e402</a>)</li>
<li><strong>phone:</strong> mobile style for phone.number (<a
href="https://redirect.github.com/faker-js/faker/issues/3583">#3583</a>)
(<a
href="cd5953b5bf">cd5953b</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>date:</strong> incorrect date boundary in past and future
(<a
href="https://redirect.github.com/faker-js/faker/issues/3827">#3827</a>)
(<a
href="0446411f6d">0446411</a>)</li>
<li><strong>finance:</strong> routingNumber now uses real
FederalReserveRoutingSymbol from lookup table. (<a
href="https://redirect.github.com/faker-js/faker/issues/3429">#3429</a>)
(<a
href="c53c1fefb6">c53c1fe</a>)</li>
<li><strong>food:</strong> correct fruit and vegetable locale data (<a
href="https://redirect.github.com/faker-js/faker/issues/3800">#3800</a>)
(<a
href="30a812a52e">30a812a</a>)</li>
<li>handle single-character fromRegExp patterns (<a
href="https://redirect.github.com/faker-js/faker/issues/3877">#3877</a>)
(<a
href="ec6cc3bbfd">ec6cc3b</a>)</li>
<li><strong>helpers:</strong> ignore wrapper characters in fromRegExp
(<a
href="https://redirect.github.com/faker-js/faker/issues/3795">#3795</a>)
(<a
href="9e2c0e391b">9e2c0e3</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="5fb3b2d67c"><code>5fb3b2d</code></a>
chore(release): 10.5.0 (<a
href="https://redirect.github.com/faker-js/faker/issues/3898">#3898</a>)</li>
<li><a
href="f89348f34a"><code>f89348f</code></a>
chore(deps): lock file maintenance (<a
href="https://redirect.github.com/faker-js/faker/issues/3861">#3861</a>)</li>
<li><a
href="e157511b6c"><code>e157511</code></a>
chore(deps): pin dependencies (<a
href="https://redirect.github.com/faker-js/faker/issues/3896">#3896</a>)</li>
<li><a
href="77dbfae4e8"><code>77dbfae</code></a>
chore(deps): update prettier to v3.8.4 (<a
href="https://redirect.github.com/faker-js/faker/issues/3893">#3893</a>)</li>
<li><a
href="123193a602"><code>123193a</code></a>
chore(deps): update pnpm/action-setup action to v6 (<a
href="https://redirect.github.com/faker-js/faker/issues/3894">#3894</a>)</li>
<li><a
href="8244a24aff"><code>8244a24</code></a>
chore(deps): update devdependencies (<a
href="https://redirect.github.com/faker-js/faker/issues/3871">#3871</a>)</li>
<li><a
href="68bc79a748"><code>68bc79a</code></a>
chore(deps): update eslint (major) (<a
href="https://redirect.github.com/faker-js/faker/issues/3875">#3875</a>)</li>
<li><a
href="e43b21bfb3"><code>e43b21b</code></a>
chore(deps): update all non-major dependencies (<a
href="https://redirect.github.com/faker-js/faker/issues/3870">#3870</a>)</li>
<li><a
href="b6aca312d7"><code>b6aca31</code></a>
chore(deps): update vitest (<a
href="https://redirect.github.com/faker-js/faker/issues/3872">#3872</a>)</li>
<li><a
href="a7e8c4f2c8"><code>a7e8c4f</code></a>
chore(deps): update mcr.microsoft.com/devcontainers/typescript-node:24
docker...</li>
<li>Additional commits viewable in <a
href="https://github.com/faker-js/faker/compare/v9.9.0...v10.5.0">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for <code>@​faker-js/faker</code> since your current
version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@faker-js/faker&package-manager=npm_and_yarn&previous-version=9.9.0&new-version=10.5.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-09-04 14:09:30 +05:30
Tanmay Deep Sharma
7bd4ecacf2 feat(voice): per-inbox call recording and transcription settings (#15621)
Admins can now decide, per voice inbox, whether calls are recorded and
whether recordings are transcribed. Both settings live on the inbox's
Voice (Twilio) and Calls (WhatsApp Cloud) configuration pages: **Record
calls** switches recording off entirely for teams operating where call
recording isn't permitted, and **Transcribe recordings** — shown only
while recording is on — controls whether Captain turns each recording
into a transcript. Both default to on, so existing inboxes keep their
current behaviour.

Supersedes #15598, which is closed. Same feature, rebased on current
`develop` and reduced to a single reviewable commit.

## Closes

- https://linear.app/chatwoot/issue/CW-8001

## Screenshots

**Twilio voice inbox → Voice**

![Twilio voice recording
settings](https://raw.githubusercontent.com/tds-1/chatwoot/assets/call-recording-settings/twilio-voice-recording-settings.png)

**WhatsApp Cloud inbox → Calls**

![WhatsApp calls recording
settings](https://raw.githubusercontent.com/tds-1/chatwoot/assets/call-recording-settings/whatsapp-calls-recording-settings.png)

## How to test

1. Open a Twilio voice inbox → Settings → **Voice**, or a WhatsApp Cloud
inbox with calling enabled → **Calls**. Two new toggles appear below
"Allow incoming calls": Record calls and Transcribe recordings.
2. Turn **Record calls** off. The Transcribe toggle disappears and the
setting saves immediately.
- Twilio: place or receive a call — no recording appears on the call
bubble or the Calls page afterwards.
- WhatsApp: accept or place a call — the browser never starts recording,
and no recording is attached when the call ends.
3. Turn **Record calls** back on and turn **Transcribe recordings** off.
Complete a call: the recording is attached but no transcript is
generated (including when Captain reads the conversation). An ordinary
voice note on the same inbox still transcribes.
4. Turn both back on (the defaults) — recording and transcription behave
exactly as before this change.
5. As an agent (non-admin), the toggles are read-only: the API rejects
changes with 401.

## What changed

**One decision point per setting.** Recording is decided once, when the
`Call` row is created: `before_create` snapshots the inbox's flag into
`meta`, and every consumer — Twilio's per-leg conference TwiML, the ring
broadcast, `/initiate`, `GET /whatsapp_calls/:id` — reads
`call.recording_enabled?`. Because Twilio asks for TwiML separately for
each leg and the agent's browser reads a payload of its own, a live
lookup could hand the same call two different answers; the snapshot
means it can't, and flipping the toggle mid-call never changes a call
already under way.

Transcription is deliberately *not* snapshotted: it is read when a
transcript is actually produced. `Messages::AudioTranscriptionService`
is re-entrant (Captain calls it every time it reads a conversation), so
a snapshot would permanently block transcribing a recording an admin
later decides they want transcribed.

- `Concerns::CallRecordingSettings` mixed into
`Enterprise::Channel::TwilioSms` and `Enterprise::Channel::Whatsapp`:
`recording_enabled?` / `transcription_enabled?` read from
`provider_config` and default on (only an explicit `false` disables).
Kept in the EE overlay since calling is Enterprise-only; exposed on the
inbox payload.
- New admin-only endpoint `POST
/api/v1/accounts/:id/inboxes/:id/set_call_recording`, mirroring
`set_inbound_calls` — including the `validate: false` save so WhatsApp's
remote credential re-check can't reject a toggle. It posts both flags
together, so neither can clobber the other.
- Twilio: `<Conference record>` switches between `record-from-start` and
`do-not-record`. With `do-not-record` Twilio produces no recording at
all, so no status callback fires and there is nothing to download,
attach, or discard.
- WhatsApp: the agent's browser is what records, so the decision rides
the same three payloads `sdp_offer`/`ice_servers` already ride — the
`voice_call.incoming` ring broadcast, the `/initiate` response, and `GET
/whatsapp_calls/:id` (the fallback the accept path already uses when the
ring broadcast was missed). When recording is off the `MediaRecorder` is
never created, so no audio is captured and nothing is uploaded.
- Transcription is gated in `Voice::CallTranscriptionService` (Twilio's
automatic path) and in `Messages::AudioTranscriptionService` (the
WhatsApp recording attachment path, which Captain also calls directly).
The second is scoped to `voice_call` messages, so ordinary voice notes
are unaffected.
- Shared `CallRecordingSettings.vue` used by both settings pages,
following the existing inbound-calls toggle: optimistic update, rollback
on failure.
- `WhatsappCallsController#upload_recording` refuses audio for a call
whose snapshot says no. The browser gate ships in JS, so a tab loaded
before the setting changed would otherwise still be able to store a
recording — the client gate avoids *capturing* audio, this one enforces
the policy.

## Verified against a running instance

Beyond the specs, this was exercised end to end on a local server:

- Both toggles render on the Twilio **Voice** and WhatsApp **Calls**
pages, default to on for an inbox whose `provider_config` is `{}`, and
survive a reload. Turning Record calls off hides the Transcribe toggle.
- Toggling writes both flags together (`{"recording_enabled" => true,
"transcription_enabled" => false}`) — neither clobbers the other.
- The real Twilio webhook returns `record="do-not-record"` with
recording off and `record="record-from-start"` with it on. **Snapshot
confirmed:** a call created while recording was off keeps
`do-not-record` even after the inbox is switched back on, while a call
created afterwards gets `record-from-start`.
- `Messages::AudioTranscriptionService` returns the disabled error for a
`voice_call` message when transcription is off, and does *not* block an
ordinary voice note on the same inbox, nor a `voice_call` message when
transcription is on.
- Endpoint authorization: agent → 401, admin → 200, non-voice inbox →
422 `Inbox does not support calling`.
- `upload_recording` → 422 `Call recording is disabled for this inbox`
for a call snapshotted off, 200 `uploaded` for one snapshotted on.
2026-09-04 12:50:07 +05:30
Sivin Varghese
da4898e7f5 fix: automation rules cannot be edited after closing the panel (#15674) 2026-09-03 22:44:39 +05:30
Muhsin Keloth
31ee54a91b fix(line): log invalid webhook signatures (#15671)
LINE webhook jobs currently stop silently when signature verification
fails, making it difficult to distinguish an invalid signature from
failures later in message processing.

This change adds a warning when a LINE webhook signature is rejected.
Existing webhook validation and message-processing behavior remain
unchanged.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:38:58 +04:00
Shivam Mishra
6d9b7d7b5a fix: read FRONTEND_URL from global config for SAML ACS URL (#15643)
On installations that override `FRONTEND_URL` through installation
config, SAML login broke: the SP entity ID was built from the overridden
value while the assertion consumer service URL was built from the raw
env var, so the two pointed at different hosts and the IdP posted its
assertion to the wrong place.

The SAML setup proc now reads `FRONTEND_URL` via `GlobalConfigService`,
matching how `AccountSamlSettings#set_sp_entity_id` builds the SP entity
ID.

## How to reproduce

1. Set `FRONTEND_URL` in the environment, then override it with a
different value in Super Admin installation config.
2. Configure SAML for an account and compare the generated SP entity ID
with the ACS URL sent to the IdP. Before this change they use different
hosts.

## Note

`sp_entity_id` is only generated on create, so accounts that configured
SAML under an older `FRONTEND_URL` keep a stale value and still need a
re-save. `OmniAuth.config.full_host` in
`config/initializers/omniauth.rb` also reads the raw env var, but that
affects OSS Google OAuth too and is left alone here.
2026-09-03 20:45:17 +05:30
Shivam Mishra
b18694e903 fix: improve Google OAuth signup error feedback (#15655)
Improves the Google OAuth signup error shown when someone uses a
personal email address. The message now explains that a work email is
required and stays visible for six seconds.
2026-09-03 20:44:50 +05:30
Muhsin Keloth
5f9d7a18b2 feat(contacts): show whatsapp usernames in social links (#15669)
WhatsApp username is already captured from BSUID-aware webhook payloads,
but agents could not see or preserve it consistently while editing
contacts. This change exposes the stored WhatsApp username in the
contact social links UI and keeps it intact when contact details are
saved.

The conversation contact panel also shows the username as read-only
identity metadata because that compact panel does not expose the
social-links editor. Routing and outbound addressing remain unchanged;
username remains display metadata in this PR.

### Related

Related:
https://linear.app/chatwoot/issue/CW-7804/add-bsuid-and-whatsapp-username-search-and-contact-identity-ui

### Things to know

- WhatsApp username is shown and stored under
`social_profiles.whatsapp`.
- Existing `social_whatsapp_user_name` values are preserved by both
contact edit forms.
- This does not add BSUID or WhatsApp username search yet.

### How to test

1. Open a WhatsApp contact that has
`additional_attributes.social_profiles.whatsapp` or
`additional_attributes.social_whatsapp_user_name`.
2. Confirm the conversation contact panel shows the WhatsApp username
and allows copying it.
3. Open the contact details page and confirm WhatsApp appears under Edit
social links.
4. Update another contact field and save.
5. Reload the contact and confirm the WhatsApp username is still
present.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-03 18:37:23 +04:00
Allan Nascimento
3fa95553b2 fix: keyboard shortcuts fire twice after switching conversations (keydown listener leak) (#14904) 2026-09-03 13:18:00 +05:30
Shivam Mishra
16a4c6cdd8 fix: sync embedded app context (#15660)
Dashboard apps now receive custom attribute definitions and the resolved
Chatwoot theme in their context. The context stays synchronized as
attributes and theme change, and refresh requests are routed to the
requesting iframe.
2026-09-03 13:14:41 +05:30
Shivam Mishra
e027da1ec2 fix: preserve spacing between automation conditions (#15662)
Automation rules containing consecutive label conditions, or an assignee
condition followed by a label condition, can now execute regardless of
condition order.

### What caused the failure

The automation condition filter builds a SQL expression by concatenating
the fragment generated for each condition. Label conditions recently
began using the shared `tag_filter_query` helper, whose fragments do not
include surrounding whitespace.

When a label condition followed an assignee condition or another label
condition, the concatenated SQL contained tokens such as `andEXISTS` or
`andNOT EXISTS`. PostgreSQL rejected the query with a syntax error. The
condition service rescued that error and returned `false`, so the
automation silently appeared not to match. Some condition orders
happened to produce valid SQL, which made reordering look like a
workaround.

This change pads label fragments at the automation filter boundary so
adjacent SQL tokens remain separated for every condition order.

### Closes

- [Support conversation
93445](https://app.chatwoot.com/app/accounts/1/conversations/93445)
- [Support conversation
93437](https://app.chatwoot.com/app/accounts/1/conversations/93437)

### How to reproduce

Create a message-created automation combining assignee presence and
label conditions, then trigger it with the label condition in different
positions. The automation should execute for every logically equivalent
ordering.
2026-09-03 12:59:05 +05:30
Muhsin Keloth
9550de83e4 fix(whatsapp): prevent duplicate brazil contacts in whatsapp co-existence (#14657)
When using WhatsApp Coexistence, a message sent from the native WhatsApp
app can create a duplicate contact and a separate conversation for a
customer who already exists in Chatwoot. This happens for Brazilian
numbers when WhatsApp delivers the number without the mobile 9th digit
while the existing contact is stored with the normalized number.

Chatwoot previously deduplicated incoming WhatsApp identifiers mostly at
the `contact_inbox.source_id` level. When no matching `contact_inbox`
existed, contact creation fell back to an exact `phone_number` lookup
using the raw provider number, so an existing normalized contact could
be missed. This change makes contact lookup try safe phone-number
candidates so the same customer resolves to the existing contact and
conversation history stays intact.

### Closes

-
https://linear.app/chatwoot/issue/CW-6724/duplicate-contacts-created-in-brazil-9th-digit-issue-when-using
- Closes https://github.com/chatwoot/chatwoot/issues/13986

### How to reproduce

1. Enable WhatsApp Coexistence on a self-hosted instance.
2. Ensure a contact exists with a Brazilian number including the mobile
9th digit, for example `+5511912345678`.
3. From the native WhatsApp Business app, send a message to that
customer so Chatwoot receives an echo event with the number without the
9th digit, for example `+551112345678`.
4. Before this change, Chatwoot creates a duplicate contact and a
separate conversation.
5. After this change, Chatwoot reuses the existing contact and the
message lands in the existing conversation context.

### Things to know

- Exact phone-number matches still win before generated candidates, so
existing contacts stored in the provider-delivered format are preserved.
- Brazil candidate matching is limited to legacy mobile ranges so
landlines are not rewritten into mobile-looking numbers.
- The same contact candidate logic is applied to WhatsApp Cloud,
WhatsApp Coexistence echo events, Twilio WhatsApp, and Enterprise
WhatsApp calls.

### What changed

- Added safe contact phone-number candidate lookup to
`ContactInboxWithContactBuilder`.
- Centralized candidate generation through
`Whatsapp::PhoneNumberNormalizationService#phone_number_candidates`.
- Added Brazil-specific contact candidate rules and regression coverage
for raw, normalized, landline, profile-name, Twilio, and Enterprise call
paths.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-09-03 11:45:52 +05:30
Vishnu Narayanan
bfd9d9e0ae feat: audit log message deletions with original content (#15456)
## Description

Message deletions were not recorded anywhere. This adds an enterprise
audit log entry when a message is deleted, capturing who deleted it, the
conversation and inbox context, and the original content before it is
replaced with the deleted placeholder.

The audit write lives in an enterprise controller module that prepends
the core messages controller `destroy` action, keeping the core change
to a single line. The deletion and the audit entry are written in the
same transaction, so a deletion that cannot be audited is not committed.
This does not change who is allowed to delete a message.

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

## Type of change

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

## How Has This Been Tested?

Added a request spec asserting the soft-delete still works and that an
audit log entry is created with the deleting user, request IP,
conversation/inbox context, and original content; plus a frontend helper
spec for the new entry. Existing message controller specs pass.

## 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 feature works
- [x] New and existing unit tests pass locally with my changes
2026-09-02 18:03:52 +05:30
Sivin Varghese
9c7177005b fix: prevent team icon picker clipping (#15648) 2026-09-02 16:59:36 +05:30
Muhsin Keloth
08b9992c4c fix(whatsapp): remove reconfigure feature gate (#15650)
WhatsApp Embedded Signup inboxes can now be reconfigured without
requiring the deprecated `whatsapp_reconfigure` account feature. This
keeps the existing reconfigure action available for admins while
preserving the separate Cloud gate for creating new Embedded Signup
inboxes.

The old `whatsapp_reconfigure` flag remains in the feature list as a
deprecated entry so existing `feature_flags_ext_1` bit positions stay
stable.

### Things to know

- New WhatsApp Embedded Signup inbox creation on Chatwoot Cloud still
requires `whatsapp_embedded_signup_inbox_creation`.
- The deprecated `whatsapp_reconfigure` feature should not be used for
runtime behavior.
- The Reconfigure UI copy remains unchanged.

### How to test

1. Open an existing WhatsApp Cloud inbox that was connected through
Embedded Signup.
2. Go to Settings -> Inboxes -> the inbox -> Configuration.
3. Confirm the WhatsApp Embedded Signup section shows the Reconfigure
action even when `whatsapp_reconfigure` is disabled for the account.
4. Click Reconfigure as an admin and complete the Meta flow for the same
phone number.
5. Confirm the existing inbox is updated successfully.
6. On Chatwoot Cloud, try creating a new WhatsApp Embedded Signup inbox
from an account without `whatsapp_embedded_signup_inbox_creation`;
confirm creation is still blocked.

### What Changed

- Removed `whatsapp_reconfigure` from frontend feature constants.
- Removed the backend reconfigure authorization dependency on
`whatsapp_reconfigure`.
- Marked `whatsapp_reconfigure` as deprecated in `config/features.yml`.
- Added request and component coverage for ungated reconfigure and
still-gated Cloud inbox creation.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-02 12:39:36 +04:00
Sivin Varghese
acbab7559a fix: show Initiated at in the agent's timezone (#15644)
# Pull Request Template

## Description

This PR fixes the "Initiated at" field in the conversation sidebar
showing the visitor's local time for website conversations and UTC for
email conversations. It now shows the time in the agent's timezone and
dashboard language, with the timezone label included to avoid ambiguity.

For example: `Nov 27, 2025, 9:05 PM (GMT+5:30)` or `27/11/2025، 9:05 م
(غرينتش+5:30)`.

## How to reproduce

1. Start a conversation from the live-chat widget with the visitor's
timezone different from the agent's, or use an email conversation.
2. Open the conversation in the dashboard and check Conversation
Information → Initiated at.
3. Before: the visitor's local time or UTC. After: the agent's local
time with the timezone label.

## What changed

* `ConversationInfo.vue` now parses and formats the stored timestamp
instead of displaying it as-is.
* `useExactTimestamp` now supports an opt-in `showTimeZone` flag. The
existing default formatting is reused, with the localized timezone label
appended in parentheses.
* Specs cover UTC, DST abbreviations (PST/PDT), whole and fractional
offsets, date-line rollover, localized and RTL timezone labels, and the
default format.


Fixes
https://linear.app/chatwoot/issue/CW-7983/initiated-at-in-conversation-information-is-not-shown-in-the-agents

## Type of change

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

## How Has This Been Tested?

### Screenshots

**Before**

<img width="357" height="125" alt="image"
src="https://github.com/user-attachments/assets/5861d67d-d802-4938-8c35-7e7874fb2e84"
/>
<img width="357" height="125" alt="image"
src="https://github.com/user-attachments/assets/2039da42-d152-4043-b5c3-847e2b9fdb1e"
/>



**After**
<img width="357" height="125" alt="image"
src="https://github.com/user-attachments/assets/13c538ba-a2b2-4982-940a-fbe5514c262f"
/>
<img width="357" height="125" alt="image"
src="https://github.com/user-attachments/assets/dbcc145e-10a0-4683-8e8d-03e39ba4ef41"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-09-02 13:50:45 +05:30
Ijas
4e88475465 fix(captain): preserve Copilot first messages over 255 characters (#15065)
The first message sent in a new Copilot thread is used verbatim as the
thread's `title`, a string column capped at 255 characters. Messages
longer than that raised `RecordInvalid` and the send silently failed,
but only for the first message: follow-up messages are stored in a
`jsonb` column with no such cap, so the limit was invisible,
undocumented, and inconsistent between the first and later messages in
the same thread.

Separately, the Copilot input cleared itself immediately on send, before
the server confirmed success. Combined with the above, a rejected first
message's text was gone with no way to recover it.

## What changed

- Truncate the derived thread `title` instead of validating the raw
message against the column length limit, so the message itself is never
rejected for length.
- Thread an `onSend`/`onSendMessage` callback through `CopilotInput` →
`Copilot` → `CopilotContainer` that resolves to whether the send
succeeded, and only clear the input on success. On failure the typed
message is preserved so the user can retry or edit it.

## How to reproduce

1. Open any conversation and expand the Copilot panel in the right
sidebar (Captain/Copilot enabled).
2. Type more than 255 characters as the first Copilot message in that
conversation and send.
3. Before this fix: request fails with `Title is too long`, input is
cleared, message text is lost.
4. After this fix: message sends successfully, thread title is a
truncated version of the message.

Closes #15045

---------

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
Co-authored-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-09-01 22:33:36 +05:30
Tanmay Deep Sharma
79e145bc20 fix(whatsapp): skip phone registration and health check for coexistence signups (#15462)
When a business links an existing WhatsApp Business App number through
Embedded Signup coexistence, Chatwoot no longer attempts to re-register
the phone number or run the immediate post-signup health check —
preventing redundant/failing registration calls and false "disconnected"
reauthorization prompts right after a successful coexistence signup.

## How to reproduce

Meta's embedded signup popup ends with one of two events depending on
what the user selects: `FINISH` for a fresh/new number, or
`FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING` when linking a number already
active on the WhatsApp Business App (coexistence).

- New-inbox creation already recognized both events, but had no way to
tell the backend which one occurred — so coexistence signups still
redundantly called `/register` (silently swallowed) and could still run
the immediate health check, which can flag a fresh coexistence number as
"pending" (Meta's health data can lag behind the FINISH event) and
wrongly trigger reauthorization.
- The reauthorize flow (`Reauthorize.vue`) only recognized `FINISH` at
all, so completing a coexistence reauth there would silently stall.
- A third "configured reauth" shortcut (used when the inbox already has
a stored `business_account_id`/`phone_number_id`, e.g. completing a
pending registration) calls the reauthorize API immediately after
`FB.login` resolves and never looked at the postMessage event, so it had
no way to know a coexistence signup had occurred either.

## What changed

- The frontend now tracks `is_coexistence` from the
`FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING` event across all three entry
points above and passes it through to the backend.
- `Whatsapp::WebhookSetupService` skips phone registration when
`is_coexistence` is set, with a fallback check on Meta's `is_on_biz_app`
health field for callers that don't have the explicit signal.
- `Whatsapp::EmbeddedSignupService` skips the immediate post-signup
health check for coexistence signups.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-09-01 17:52:59 +05:30
Sivin Varghese
a523f65b0b feat: improve file upload experience in the article editor (#15593) 2026-09-01 17:08:28 +05:30
Marco Cabral
07fa9ce88e fix(whatsapp): preserve contacts across BSUID lifecycle changes (#15357)
WhatsApp now reports BSUID lifecycle changes as `system` messages on the
existing `messages` webhook subscription. Chatwoot currently ignores
those events, so the next message under a rotated identifier can create
a second contact and split the customer’s history.

This change records the current BSUID and parent BSUID as additional
aliases of the contact that owns the previous identifiers. Existing
aliases and conversations remain untouched. For a `user_changed_number`
event, Chatwoot also records the new phone alias and updates the contact
phone when that can be done without claiming an identifier owned by
another contact.

### Closes

Fixes
https://linear.app/chatwoot/issue/CW-7801/handle-bsuid-lifecycle-updates-and-identifier-rotation

### Things to know

- This PR is stacked on the routing foundation in #15175.
- No new webhook-field subscription is required. Meta sends both
`user_changed_user_id` and `user_changed_number` inside
`messages[].system`.
- The handler reads only Meta’s typed identifier fields; it does not
parse the human-readable system-message body.
- Previous aliases are retained so delayed webhooks still resolve
safely.
- Conversations are never repointed between `ContactInbox` rows.
- The lifecycle event does not create an agent-visible message or
conversation.
- Identifier collisions are logged and left unmerged for review.
- The new-identifier locks are shared with normal inbound routing. If a
lock cannot be acquired, the webhook job retries instead of writing
without protection.

### How to test

1. Create a WhatsApp Cloud contact with a `ContactInbox` whose source ID
is the previous BSUID.
2. Post a normal WhatsApp `messages` webhook whose message has `type:
"system"`, `system.type: "user_changed_user_id"`, and the documented
`previous_user_id` / `user_id` values.
3. Confirm the same contact now owns both source IDs, its existing
conversation still points to the original `ContactInbox`, and no visible
system message was created.
4. Repeat with `previous_parent_user_id` / `parent_user_id` and confirm
both regular and parent aliases are retained.
5. Repeat with `system.type: "user_changed_number"` and `system.wa_id`;
confirm the phone and phone alias update when unclaimed.
6. Pre-create the current identifier or phone on a different contact and
repeat. Confirm Chatwoot does not merge or move either identity and
records the conflict in logs.
7. Deliver the same event twice and confirm no duplicate aliases are
created.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-01 12:37:32 +04:00
Sojan Jose
646b47370d feat(tiktok): add inbox access request (#15637)
Chatwoot Cloud accounts without TikTok access can now contact support
directly from the TikTok channel card. Accounts with the existing
`channel_tiktok` feature continue through the current connection flow.

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

## Why

TikTok access is being reviewed manually for now. Paid plans should not
enable it automatically while this temporary process is in place.

## What this change does

- Removes TikTok from automatic paid-plan feature reconciliation.
- Keeps the channel card available as a support-chat entry point when
`channel_tiktok` is disabled.
- Hides TikTok from onboarding until the existing feature is enabled.
- Leaves self-hosted and enabled-account OAuth behavior unchanged.

## How to test

1. On Chatwoot Cloud, open Settings → Inboxes → Add inbox with
`channel_tiktok` disabled.
2. Confirm the TikTok card asks the customer to contact support.
3. Select the card and confirm the support chat opens.
4. Enable `channel_tiktok` and reload.
5. Confirm the card opens the existing TikTok connection flow.
6. Confirm TikTok is hidden from onboarding while disabled and visible
when enabled.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-01 11:42:29 +04:00
Sivin Varghese
b3233bb8cc fix: show team icons in the sidebar (#15634) 2026-09-01 12:12:30 +05:30
Muhsin Keloth
e5eaff1fa1 feat(whatsapp): enable embedded signup for paid plans (#15626)
Paid Chatwoot Cloud accounts now receive access to WhatsApp Embedded
Signup automatically when their plan features are reconciled. This
removes the support approval step for Startups, Business, and Enterprise
plans while leaving the Hacker plan's automatic entitlement unchanged.

The installation-wide Meta inbox creation switch remains independent of
plan entitlement, so the existing incident control continues to take
precedence in the product UI.

### Things to know

- This PR does not include a data migration. Existing paid accounts
require a one-time manual backfill.
- Hacker accounts are not granted the feature automatically.
Support-approved exceptions can be recorded as manually managed and will
survive plan reconciliation.
- Existing Hacker accounts approved through a direct feature toggle must
be recorded as manually managed before their next reconciliation.

### How to test

1. Reconcile features for a Startups, Business, or Enterprise account.
2. Open Settings -> Inboxes -> Add Inbox -> WhatsApp.
3. Confirm WhatsApp Embedded Signup is available without requesting
support access.
4. Reconcile a Hacker account and confirm it does not receive Embedded
Signup automatically.
5. Add Embedded Signup as a manually managed feature for a Hacker
account, reconcile it, and confirm access remains enabled.
6. Enable `DISABLE_META_INBOX_CREATION` and confirm the existing Meta
inbox creation restriction remains active in the UI.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-09-01 10:32:52 +04:00
Rusty Shackleford
dabde9ec68 fix(conversation): toggle contact and captain sidepanels on repeat click (#14415)
The contact and Captain buttons in the conversation view now toggle
their sidebar — clicking the active button closes the panel instead of
being a no-op. Previously you could only dismiss either panel via its
internal close button or the Alt+O shortcut.

## How to test
1. Open any conversation.
2. Click the contact (person) icon in the right-edge button group →
contact sidebar opens.
3. Click it again → contact sidebar closes.
4. Click the Captain icon → copilot panel opens (closing contact if
open).
5. Click it again → copilot panel closes.
6. Press Alt+O → contact sidebar toggles (unchanged from before).

## What changed
- `SidepanelSwitch.vue`: replaced the two click handlers with toggle
logic matching the existing keyboard-shortcut helper (which is now the
single source of truth and is also bound to the contact button click).
The Captain button gets equivalent toggle behavior.
- Mutual exclusion is preserved: opening one panel still closes the
other.

Co-authored-by: Devi R <devi@chatwoot.com>
2026-08-31 23:05:15 +05:30
Petterson
ce768074f0 fix(help-center): drop leading @ from pt_BR social link placeholder (vue-i18n crash) (#14857)
## Pull Request Description

The Help Center portal settings "Social links" section (documentation
layout) crashes for **pt_BR** users the moment a network is added. The
`pt_BR` value of
`HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.PLACEHOLDER` is
`"@usuario"`. vue-i18n treats a leading `@` as the linked-message
marker, so compiling that message throws `SyntaxError: Invalid linked
format` when the input field renders (i.e. right after a platform is
added), which unmounts the whole settings section.

English is unaffected (`PLACEHOLDER: "handle"`); pt_BR is the only
locale whose placeholder starts with `@`. This drops the leading `@`
(`"usuario"`) — the network prefix (e.g. `facebook.com/`) is already
shown next to the field, so the `@` wasn't needed.

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

## How to reproduce
With the dashboard in Portuguese: Help Center → Settings →
"Documentation" layout → "Add social link" → click a network → the
section disappears (console: `Invalid linked format`).

## Checklist
- [x] My code follows the style guidelines of this project
- [x] New and existing unit tests pass locally with my changes

---------

Co-authored-by: Devi R <devi@chatwoot.com>
2026-08-31 19:45:23 +05:30
Muhsin Keloth
65952920c4 fix(whatsapp): preserve phone conversations for mixed BSUID payloads (#15630)
WhatsApp can send both the customer phone number and BSUID in the same
webhook. When the phone-backed conversation already exists, agents and
customers expect that conversation to continue instead of seeing a
second BSUID-backed conversation created only because the webhook also
carried an identifier.

This PR routes mixed phone+BSUID webhook payloads to the identity that
preserves existing conversation history. Phone-backed history wins when
present, BSUID-only history is preserved when that is the only existing
thread, and brand-new mixed customers still start on the phone identity.
BSUID-only webhooks continue to create or reuse the BSUID ContactInbox.
The same ordering is shared by Meta Cloud messages, message echoes, and
inbound WhatsApp calls.

Related: https://github.com/chatwoot/chatwoot/pull/15175
Related: https://github.com/chatwoot/chatwoot/pull/15546

### Things to know

- This does not repoint existing conversations or merge conflicting
ContactInbox rows.
- BSUID aliases are still synced to the same contact when a mixed
payload resolves through the phone identity.
- If the phone is absent, Chatwoot still routes through the BSUID
identity.
- This directly covers the production shape where Meta sent both `from`
and `from_user_id` for an inbound WhatsApp call.

### How to test

1. Receive a Meta Cloud WhatsApp message or inbound call webhook that
includes both `from`/`wa_id` and `from_user_id` for a contact with an
existing phone-backed conversation.
2. Confirm the new message/call is added to the existing phone-backed
conversation and no new BSUID-backed conversation is created.
3. Confirm a BSUID ContactInbox is still created for the same contact.
4. Send a BSUID-only follow-up and confirm it creates or reuses the
BSUID-backed conversation.
5. Start with a BSUID-only conversation, then receive a mixed
phone+BSUID webhook, and confirm Chatwoot keeps the existing BSUID
conversation instead of creating a new phone thread.
6. Repeat with conflicting phone and BSUID ContactInbox records and
confirm Chatwoot does not merge or repoint either contact.

### What changed

- Added a shared WhatsApp source-ID orderer for message and call
routing.
- Updated Meta Cloud message and echo routing to prefer existing
conversation continuity.
- Updated inbound WhatsApp call identity ordering to use the same rule.
- Updated specs for mixed phone+BSUID, BSUID-only, existing phone
history, existing BSUID-only history, and conflict safety.

Validation:

```bash
bundle exec rspec spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb spec/enterprise/services/whatsapp/inbound_call_identity_builder_spec.rb spec/enterprise/services/whatsapp/incoming_call_service_spec.rb spec/enterprise/services/voice/inbound_call_builder_spec.rb
bundle exec rubocop --cache false app/services/whatsapp/identity_source_id_orderer.rb app/services/whatsapp/incoming_message_identifier_helper.rb enterprise/app/services/whatsapp/inbound_call_identity_builder.rb spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb spec/enterprise/services/whatsapp/inbound_call_identity_builder_spec.rb spec/enterprise/services/whatsapp/incoming_call_service_spec.rb spec/enterprise/services/voice/inbound_call_builder_spec.rb
```

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-31 14:39:56 +04:00
Sony Mathew
5ea936e03f fix(inboxes): hide Facebook and X identifiers (#15628)
Facebook and X inboxes now keep the editable inbox name as the visible
label without appending opaque provider IDs in the sidebar or inbox
settings. This follows the same behavior introduced for Instagram and
TikTok in [#15611](https://github.com/chatwoot/chatwoot/pull/15611).

## Closes


[CW-7245](https://linear.app/chatwoot/issue/CW-7245/show-phone-numberchannel-identifier-consistently-in-inbox-ui)

## How to reproduce

1. Open a workspace with connected Facebook and X inboxes.
2. Check the Channels section in the conversation sidebar.
3. Open Settings → Inboxes, then open either inbox settings page.
4. On `develop`, the inbox name is followed by an opaque page or profile
ID. On this branch, only the inbox name remains visible.

## What changed

- Stop resolving Facebook page IDs and X profile IDs as display
identifiers.
- Keep both IDs in the inbox payload for routing and provider
operations.
- Preserve identifier display and search behavior for every other
supported channel.
2026-08-31 14:33:48 +05:30
Muhsin Keloth
c086fe8754 feat(whatsapp): add guided manual inbox setup (#15079)
Adds a guided manual setup flow for WhatsApp inboxes so administrators
can prepare their Meta app, enter the required IDs and permanent token,
review the number returned by Meta, create the inbox, and verify webhook
setup without relying on Embedded Signup.

Fixes
https://linear.app/chatwoot/issue/CW-7634/add-guided-whatsapp-manual-inbox-setup

**Preview**

<img width="2672" height="1472" alt="CleanShot 2026-07-20 at 14 24
44@2x"
src="https://github.com/user-attachments/assets/f9912939-0899-43dc-a442-63d0d10439f1"
/>


## Things to know

- Meta App ID and App Secret are not requested or stored. Payload
signature verification remains unchanged for customer-owned manual apps.
- The committed walkthrough media is neutral placeholder content.
Replace it with the final sanitized videos and posters before merge.

## How to test

1. Start creating a WhatsApp inbox and choose Manual setup.
2. Complete the Meta app, phone and WABA ID, and permanent-token steps.
3. Confirm Chatwoot retrieves the verified name and phone number, then
create the inbox.
4. Verify the webhook URL and individual connection checks are
displayed.
5. Confirm you can retry webhook setup or continue to agent assignment
while a check is pending.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-31 12:27:15 +04:00
Sivin Varghese
9d51dae0b4 fix: moved learn more hyperlink below the description (#15627)
# Pull Request Template

## Description

This PR fixes the Templates page using different "Know more" link copy
and a hardcoded chatwoot.com URL, which also remained visible on
custom-branded installs. It now uses the shared settings header link,
matching the copy used across the product ("Learn more about WhatsApp
templates") and automatically hiding the link on custom-branded
instances.

It also fixes the "Last sync attempt" line appearing between the
description and the link. It now renders below the link as a secondary
line.

Fixes
https://linear.app/chatwoot/issue/CW-8056/remove-hyperlink-from-templates-page-header

## Type of change

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

## How Has This Been Tested?

### Screenshots

**Before**
<img width="1249" height="338" alt="image"
src="https://github.com/user-attachments/assets/23bc3b21-07c8-45c4-843e-d64928a5171b"
/>

**After**
<img width="1249" height="338" alt="image"
src="https://github.com/user-attachments/assets/8c5878f9-44c2-4fe4-837e-c7b57bb26aec"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-31 12:15:24 +04:00
radaloshara
b9388934bf fix: Handle Telegram callback queries and acknowledge inline callbacks (#13678)
## Description

This PR fixes Telegram callback query handling for standard and Business
chats.

### What was wrong

- Inline button callbacks were not acknowledged, so Telegram clients
could keep showing a loading spinner.
- Business callbacks could be attributed to the wrong contact or
classified with the wrong message direction.
- Callback queries from unsupported group chats could bypass the
private-chat guard.

### What changed

- Acknowledge callbacks immediately in the webhook JSON response using
Telegram's `answerCallbackQuery` method, before background processing
starts.
- Treat `callback_query.from` as the user who pressed the button and
compare it with `message.chat.id` to distinguish customer clicks from
Business-account-owner clicks.
- Preserve the customer contact and conversation routing for outgoing
Business callbacks.
- Validate callback payload shape and ignore unsupported inline-mode,
game, malformed, group, and supergroup callbacks during message
ingestion while still acknowledging them at the webhook boundary.

### Tests

- Standard callback ingestion and response-only acknowledgement.
- Incoming customer and outgoing Business-account-owner callback
classification.
- Customer contact attributes and conversation routing.
- Group, supergroup, inline-mode, game, mismatched-participant, and
malformed payload filtering.
- Webhook parameter forwarding and immediate `answerCallbackQuery`
response.

Fixes #13677

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update required

---------

Co-authored-by: Artur Saakyan <artur@Arturs-MacBook-Air-2.local>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sony Mathew <ynos1234@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-08-31 11:12:02 +04:00
Pedro Lis French
56e533cd07 fix(messages): prevent out-of-order webhook status regressions (#14149)
## Description

Twilio and WhatsApp Cloud API send multiple webhook callbacks for
message status updates (queued → sent → delivered → read), but delivery
order is not guaranteed. When webhooks arrive out of order, message
status can regress (e.g., `delivered` → `sent`), causing incorrect
status display and potentially breaking downstream integrations that
rely on status accuracy.

**Root cause**: `Twilio::DeliveryStatusService` and
`Whatsapp::IncomingMessageBaseService` were directly assigning status
without validating if the transition was a regression. The existing
`Messages::StatusUpdateService` had partial validation but wasn't being
used.

**Solution**: 
- Enhanced `Messages::StatusUpdateService` to enforce forward-only
status transitions based on the Message status enum priority (`sent=0 →
delivered=1 → read=2`)
- Refactored both Twilio and WhatsApp services to use
`StatusUpdateService` instead of direct assignment
- `failed` status is allowed from any state (errors can happen at any
point)

Fixes N/A (internal bug report from production logs)

## Type of change

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

## How Has This Been Tested?

**Unit tests added:**
- `spec/services/messages/status_update_service_spec.rb` - Tests for
forward transitions, regression prevention, and failed state handling
- `spec/services/twilio/delivery_status_service_spec.rb` - Out-of-order
webhook scenarios for Twilio
- `spec/services/whatsapp/incoming_message_service_spec.rb` -
Out-of-order webhook scenarios for WhatsApp

**Manual verification:**
1. Identified the bug via production logs showing `delivered → sent`
regression
2. Traced the timeline: message 94393 received `delivered` at 19:31:52,
then `sent` at 19:31:53 (~800ms late)
3. Confirmed the fix prevents regression by checking `new_priority >=
current_priority`

**To reproduce the original bug:**
```ruby
# Rails console - simulate out-of-order webhook
message = Message.find(id)
message.update!(status: 'delivered')

# Late "sent" webhook arrives
Twilio::DeliveryStatusService.new(params: { MessageSid: message.source_id, MessageStatus: 'sent', ... }).perform

# Before fix: status = 'sent' (wrong)
# After fix: status = 'delivered' (correct)
```


## 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pedro Lis French <pedrolis@lahaus.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-31 10:56:48 +04:00
Sivin Varghese
186fba53e3 chore: format hover timestamps in the user's locale (#15600) 2026-08-31 12:06:03 +05:30
Marco Cabral
11ecd7a30c fix(whatsapp): keep conversations on the active BSUID identity (#15175)
> **The direction of this PR changed mid-flight.** The first version
repointed the existing conversation onto the BSUID `ContactInbox`. After
the discussion in chatwoot/chatwoot#14931 and the production report in
chatwoot/chatwoot#15359, Chatwoot decided **not** to re-anchor the phone
conversation. This version implements the agreed direction instead. The
Linear slug below still reads "repoint" for historical reasons.

When a WhatsApp Cloud contact starts messaging under a BSUID, Chatwoot
resolves whichever `ContactInbox` already exists — normally the
phone-backed one — and, since chatwoot/chatwoot#15098, looks for a
conversation across the whole contact. Two problems follow from that:
the first BSUID payload stays on the stale phone identity, and a contact
that was merged in the dashboard can have a reply answered through a
different person's `source_id`.

This change makes the business scoped user id the identity a Cloud
payload lands on, and scopes conversation reuse to the `ContactInbox`
that resolved the message. The phone conversation is left untouched and
stays reachable under **Previous conversations**.

Fixes chatwoot/chatwoot#14931<br>Fixes chatwoot/chatwoot#15359

Related: chatwoot/chatwoot#14931 <br>Related: chatwoot/chatwoot#15098
<br>Related: chatwoot/chatwoot#15150 <br>Related:
chatwoot/chatwoot#15359

## What changed

**Identifier ordering — parent BSUID, regular BSUID, phone.** On
`whatsapp_cloud` the parent identifier leads. Meta documents it as
addressable through `recipient` for messages and calls alike, and an
event carrying both identifiers and a later parent-only event describe
the same sender — leading with the parent keeps them on one
`ContactInbox` instead of anchoring each on a row of its own. This also
matches the precedence `Webhooks::WhatsappEventsJob` already uses when
it picks the mutex key. Every other provider keeps the previous order,
phone first.

Applied to the three entry points so they resolve the same row:

*
`Whatsapp::IncomingMessageIdentifierHelper#incoming_message_source_ids`
*
`Whatsapp::IncomingMessageIdentifierHelper#outgoing_message_source_ids`
(echoes)
* `Whatsapp::InboundCallIdentityBuilder`

`ContactInboxSourceIdResolver` **— new opt-in**
`prefer_first_source_id:`**,** default `false`. When a caller opts in
and the existing row is not the preferred identifier, the `ContactInbox`
for the preferred identifier is created on the same contact, rescuing
`RecordNotUnique`. Nothing is repointed or destroyed. The insert runs in
a savepoint, because inbound calls resolve identity inside an outer
transaction and on PostgreSQL a unique violation would otherwise abort
it. `Twilio::WhatsappIdentifierHelper` does not opt in and is unchanged.

**Conversation lookup scoped to the exact** `ContactInbox`**.**
`Whatsapp::IncomingMessageBaseService#set_conversation` uses
`@contact_inbox.conversations` instead of `@contact.conversations`, only
where identifiers are addressable.

**WhatsApp calls now follow the same identity behaviour.**
`Voice::InboundCallBuilder` no longer looks up conversations
contact-wide for WhatsApp, and no longer repoints a reused conversation
— both could route a call to another identity after a dashboard contact
merge. Twilio voice keeps its existing behaviour.

## How to reproduce

1. On a Meta Cloud inbox, receive a message carrying a phone number and
a business scoped user id. Chatwoot creates the contact and the
conversation against the phone-backed `ContactInbox`.
2. Receive a later message from the same contact carrying only the
identifier.

**Before:** the conversation is reused but stays attached to the phone
identity, so an agent reply can be addressed to a number that is no
longer reachable.

**After:** the payload anchors on the business scoped user id — the
parent one when present — a conversation opens on that `ContactInbox`,
and the phone conversation remains unchanged under **Previous
conversations**.

## How to test

* Send a message carrying phone, regular and parent identifiers on a
Meta Cloud inbox. Confirm the conversation anchors on the **parent**
`ContactInbox`, and that the phone conversation, if one existed, is
intact under *Previous conversations*.
* Follow it with a **parent-only** payload and confirm it reuses that
same conversation rather than opening another.
* Reply from the conversation and confirm it goes out through the
`recipient` path from chatwoot/chatwoot#15150.
* Place an inbound WhatsApp call from the same contact and confirm it
lands on the same `ContactInbox` a message would, without repointing
anything.
* Repeat on a `default` (360Dialog) inbox and confirm a BSUID-only
follow-up **keeps the single existing conversation** — the identifier is
not addressable there.
* Confirm ordinary phone-backed Cloud traffic, Twilio conversations and
Twilio voice are unchanged.

## Behaviour change worth a line in the release notes

A contact whose **first** message already carries the identifier is
unaffected: it is created on that `ContactInbox` and nothing splits.

A contact that already had a phone-backed conversation gains one extra
conversation, once. Because that is a real new conversation:

* SLA and first-response timers restart
* assignee and team are not inherited
* `conversation_created` automations fire again
* conversation-level `custom_attributes` do not carry over
* integrations that key state on `conversation_id` lose the thread

None of these are defects — they follow directly from "new identity, new
conversation". They are listed because they are what operators notice
first.

## Provider note

The ordering and the scoping both sit behind `addressable_identifiers?`
(`provider == 'whatsapp_cloud'`).
`Whatsapp::Providers::WhatsappCloudService` is the only provider that
builds `recipient_params`; `default` (360Dialog) always sends `to:` and
cannot address one of these identifiers, so anchoring a thread there
would create a conversation that can never be replied to — Meta answers
`200` and drops the message, the failure mode chatwoot/chatwoot#15150
fixed. A spec pins this.

## Out of scope

This PR does not implement BSUID rotation, `user_id_update`, or campaign
addressing.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-31 10:20:54 +04:00
Tanmay Deep Sharma
0c0ab96659 fix(whatsapp): remux call recordings to ogg so files carry a duration (#15512)
WhatsApp call recordings made from the dashboard are captured by the
browser's MediaRecorder, which produces a WebM file whose duration
header is never filled in. The file plays on the web (browsers buffer
the whole file and infer the length), but on the mobile apps the missing
duration breaks playback — on Android the seek slider never moves and
playback stops the moment it starts, and the format compounds a separate
iOS decode limitation. Recordings are now remuxed to OGG/Opus in the
browser before upload, so the file carries an exact, standards-based
duration that mobile players read natively — and matches the format
voice notes already use.

## Screenshots
After conversion: (Audio playable) 
<img width="1071" height="1440" alt="IMG_6834"
src="https://github.com/user-attachments/assets/b951d124-3da5-4720-beb3-e89a439a297c"
/>

Before conversion state: (Audio not playable)
<img width="1064" height="1594" alt="IMG_6835"
src="https://github.com/user-attachments/assets/0984fa32-ba90-4555-b37d-176d5a8450a2"
/>


## Closes

-
[CW-7926](https://linear.app/chatwoot/issue/CW-7926/whatsapp-call-audio-is-not-working-on-mobile-application)

## How to reproduce

1. On a WhatsApp inbox with calling enabled, make a call from the
dashboard and hang up.
2. Open the resulting conversation in the Android app and play the call
recording: the slider doesn't move and playback ends immediately. On iOS
the recording doesn't play at all.
3. Inspect the attachment: it is `call-recording.webm` with no duration
in the container.

## What changed

- `stopRecorderAndUpload` in `useWhatsappCallSession.js` now passes the
recorded blob through the existing `remuxWebmToOgg` utility (the same
in-repo, dependency-free remuxer voice notes use) and uploads it as
`call-recording.ogg`.
- This is a pure container rewrite — the Opus audio bytes are copied
unchanged, so there is no re-encode cost (milliseconds at hangup, in the
agent's browser). OGG granule positions give the file an exact duration
readable without downloading the whole file.
- If remuxing fails for any reason, the raw WebM is uploaded under the
old name, preserving today's behavior; OGG input (Firefox records real
OGG) passes through untouched.
- No backend changes — the upload endpoint and attachment handling are
format-agnostic.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-08-31 11:11:55 +05:30
Sojan Jose
642223fb31 feat: add Captain conversation assignment (#15421)
Makes connected Captain assistants explicit conversation owners through
the unified AI-assignee association. Builds on the merged ownership
foundation in [#15419](https://github.com/chatwoot/chatwoot/pull/15419).

## Closes

-
https://linear.app/chatwoot/issue/CW-7451/add-explicit-captain-ownership-backend

## Why

Captain-handled conversations currently rely on `pending` plus inbox
configuration to imply ownership. Explicit ownership lets assignment,
takeover, live updates, filters, and future UI controls distinguish
Captain-handled conversations from ordinary pending conversations.

## What this change does

- Reconciles AgentBot ownership changed during the discriminator rollout
before typed reads become authoritative.
- Assigns eligible new Captain-handled conversations to the connected
assistant while leaving out-of-audience conversations open.
- Supports assigning the connected Captain through the existing
conversation assignment API.
- Clears human ownership and moves Captain-assigned conversations to
pending.
- Clears Captain ownership and opens the conversation when a human takes
over through assignment or a public reply.
- Returns Captain as the typed assignee in assignment, conversation, and
live-update payloads.
- Excludes AgentBot- and Captain-owned conversations from human-assignee
ID filters.
- Keeps AgentBot and Captain ownership isolated when their numeric IDs
overlap.
- Preloads typed AI owners when rendering conversation lists.
- Omits stale Captain ownership metadata safely when the referenced
assistant no longer exists.

## Review guide

1. Typed AI-owner reconciliation, authoritative reads, and preloading.
2. Captain assignment and conversation lifecycle.
3. API serialization and live assignment updates.
4. Human takeover and client-side assignee filtering.

## Validation

Assign the connected Captain to an open, resolved, or snoozed
conversation and confirm it becomes pending with Captain shown as owner.
Assign a human or send a public human reply and confirm Captain
ownership clears and the conversation opens. Apply human-assignee equal
and not-equal filters and confirm Captain-owned conversations are
excluded. Attempt to assign a Captain that is not connected to the inbox
and confirm ownership does not change.
2026-08-29 03:34:49 -07:00
Sojan Jose
7f029b65eb fix: render team metadata for agent bot requests (#15612)
Agent bots can now fetch team-assigned conversations without the
response failing while rendering team membership metadata.

## Closes


https://linear.app/chatwoot/issue/CW-8042/actionviewtemplateerror-undefined-method-teams-for-an-instance-of

## Why

Agent-bot access tokens are allowed to fetch conversation details. When
the conversation had a team, the shared team serializer assumed the
authenticated resource was always a user and called `teams` on the agent
bot, returning a 500 response.

## What this change does

- Reports `is_member` only for authenticated users.
- Returns `is_member: false` for agent-bot requests.
- Covers fetching a team-assigned conversation with an agent-bot token.

## How to test

1. Assign a conversation to a team.
2. Fetch the conversation details using an agent-bot access token.
3. Confirm the request succeeds and the team metadata includes
`is_member: false`.
2026-08-28 16:36:51 -07:00
Sony Mathew
180321a970 fix(inboxes): hide opaque social identifiers (#15611)
Instagram and TikTok inboxes now keep their provider account name as the
visible label without appending opaque provider IDs in the sidebar or
inbox settings. This is a follow-up to
[#15527](https://github.com/chatwoot/chatwoot/pull/15527) and
[#15528](https://github.com/chatwoot/chatwoot/pull/15528).

## Closes


[CW-7245](https://linear.app/chatwoot/issue/CW-7245/show-phone-numberchannel-identifier-consistently-in-inbox-ui)

## How to reproduce

1. Open a workspace with connected Instagram and TikTok inboxes.
2. Check the Channels section in the conversation sidebar.
3. Open Settings → Inboxes, then open either inbox settings page.
4. On `develop`, the provider account name is followed by a large opaque
Instagram or TikTok ID. On this branch, the account name remains visible
without the opaque ID.

## What changed

- Stop resolving Instagram account IDs and TikTok business IDs as
display identifiers.
- Keep both IDs in the inbox payload for matching, reauthorization, and
provider operations.
- Preserve identifier display and search behavior for every other
supported channel.
2026-08-28 11:46:15 -07:00
Vishnu Narayanan
b7d857d517 fix(secutiy): verify Slack webhook request signatures (#15480)
## Description

Adds Slack signing-secret verification to the Slack events webhook
(`POST /api/v1/integrations/webhooks`) so inbound events are confirmed
to originate from Slack before they are processed. The
`X-Slack-Signature` HMAC is checked against the request body with a
timestamp-freshness window.

Verification only runs when `SLACK_SIGNING_SECRET` is configured; when
it is not set, requests are processed as before so existing
installations are not affected. **To enable verification, set
`SLACK_SIGNING_SECRET`** (via env or the super-admin config) to the
Slack app's signing secret.

Ref https://linear.app/chatwoot/issue/CW-7465

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2026-08-28 19:27:36 +05:30
Vishnu Narayanan
f8e165519c fix(security): fetch non-provider SMS media through the safe fetcher (#15466)
## Description

Inbound SMS media is downloaded from URLs contained in the webhook
payload. For hosts other than the provider, this change routes the
download through `SafeFetch`, which resolves the host and rejects
private/internal addresses (revalidating each redirect hop).
Provider-hosted media continues to be fetched directly.

Stacked on #15463 (same method); please review/merge that first.

Ref https://linear.app/chatwoot/issue/CW-7469

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2026-08-28 16:00:39 +05:30
Aakash Bakhle
8e3d510b13 feat: add conditions to delayed automations (#15472)
## Summary

Delayed automations can now include additional conditions joined with
`AND`, including a Pending status condition. The form keeps these
conditions inside the wait section and uses the existing automation
controls.

Delayed jobs recheck all saved conditions before running an action. The
action is skipped when a condition no longer matches.

Saved delayed rules are not normalized merely by opening the editor.
API-created operators such as `inbox_id not_equal_to` remain unchanged
until an administrator edits a wait control.

The Captain timer conflict warning is split into the stacked follow-up
#15482.

## Validation

- 39 focused frontend tests passed.
- 75 focused backend examples passed.
- ESLint, Prettier, RuboCop, JSON parsing, and `git diff --check`
passed.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-28 15:45:02 +05:30
Vishnu Narayanan
8b1e08fc66 fix(security): scope SMS media download credentials to the provider host (#15463)
## Description

Inbound SMS media is downloaded from URLs contained in the webhook
payload. This change attaches the channel's provider credentials only
for media on the provider's own host (derived from the channel's API
base path), and does not follow redirects on that request. Media on
other hosts is downloaded without credentials.

Ref https://linear.app/chatwoot/issue/CW-7469

## Type of change

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

## How Has This Been Tested?

Added specs in `spec/services/sms/incoming_message_service_spec.rb`
covering credential handling by media host and redirect behaviour. Full
spec file green (9 examples, 0 failures), rubocop clean.
2026-08-28 10:13:23 +00:00
William Bisimwa
e7bbc82d91 fix: prevent greeting messages from firing on activity events (#3657) (#14278)
Greeting messages currently fire when an `activity` message (status
change, label added, assignee change, resolve, etc.) is created on a
conversation that has a prior incoming message but no outgoing message
yet. The agent appears to send a greeting for an action they didn't
take.

Closes #3657

## What changed

`should_send_greeting?` in
`app/services/message_templates/hook_execution_service.rb` did not check
whether the triggering message is incoming. The existing guards
(`campaign`, `tweet`) and `first_message_from_contact?` were not enough:
an `activity` message on a conversation that has never had an outgoing
reply still satisfied `first_message_from_contact?`, so the greeting
fired.

The fix adds one line:

```ruby
return false unless message.incoming?
```

This mirrors the existing pattern in
`should_send_out_of_office_message?` in the same file, which already
guards on `message.incoming?` for the same reason.

## Why a single guard, not a refactor

`first_message_from_contact?` is a property of the conversation as a
whole, not of the triggering message. Pushing the incoming check into
that helper would conflate two concepts. Adding the guard at the
`should_send_greeting?` level keeps each method's responsibility clean
and matches the pattern already used by the sibling
`should_send_out_of_office_message?` method.

## Alternatives considered

- **Guard at the top of `perform`.** Would also affect `EmailCollect`
and `OutOfOffice`, broadening scope. Rejected — `OutOfOffice` already
has its own incoming guard, and `EmailCollect` has different semantics
around web-widget contact email collection.
- **Tighten `first_message_from_contact?` to also check
`message.incoming?`.** Reasonable but conflates message-level and
conversation-level logic.

## Tests

Two new specs in
`spec/services/message_templates/hook_execution_service_spec.rb`
covering the missing cases:

1. An activity message on a conversation with a prior incoming reply
does not retrigger the greeting.
2. An outgoing agent message also does not retrigger the greeting.

Both tests construct the bug's preconditions explicitly: greeting is
enabled *after* the prior incoming message exists, simulating the
realistic scenario where greetings were toggled on after the
conversation was already underway. Without that detail the bug would be
masked by the real `Greeting` service running during the incoming
message's `after_commit` and creating a `template` message.

The first new test fails on `develop` and passes with the fix —
confirming the regression coverage.

## How to verify

```bash
bundle exec rspec spec/services/message_templates/hook_execution_service_spec.rb
```

All 16 specs (14 existing + 2 new) pass.

Manually:
1. Enable greeting on an inbox in a fresh local Chatwoot
2. Open an existing conversation with prior incoming messages and zero
outgoing replies
3. Trigger any activity (resolve, label, assignee change)
4. Before this change: greeting message appears in the conversation
immediately after the activity. After this change: no greeting fires.

## Notes

- Comment wording on the new guard line matches the existing
`should_send_out_of_office_message?` line for consistency, even though
`incoming?` filters more than just "outbound" (it also excludes
`activity` and `template` messages).
- This issue was filed by a maintainer (`pranavrajs`) in Dec 2021 with
severity 4 — included for context, not because the original report needs
anything more than what's here.
2026-08-28 15:20:09 +05:30
Muhsin Keloth
7419fa5c8e fix(conversations): seed message thread by highest id so CSAT survey renders (#14927)
On conversations that were auto-resolved (the 10-minute inactivity
flow), a customer's CSAT rating showed up in CSAT reports but the
survey/rating never appeared in the conversation itself. This makes
agents unable to see feedback that reports are counting, and reads like
a reporting bug. This PR makes the survey render in the conversation
again.

Fixes
https://linear.app/chatwoot/issue/CW-7520/csat-rating-shows-in-reports-but-not-in-the-conversation-survey-bubble

## How to reproduce

1. On a web-widget inbox with CSAT enabled, let a conversation
auto-resolve via the inactivity automation (so the `input_csat` survey
and the "resolved by system" activity note are created in the same
second).
2. Have the customer submit a rating.
3. Open the conversation in the dashboard: the CSAT star bubble is
missing, even though the rating appears in the CSAT report.

Appending any newer message (e.g. reopening and re-resolving the
conversation) makes the rating reappear — which pointed straight at the
root cause.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-28 14:20:21 +05:30
Pranav
bdde38b55b feat(llm): include conversation created time in LLM text format (#15145)
Added created time on LLM Context
2026-08-28 11:46:42 +05:30
Pranav
50aff719e0 feat(slack): add alerts only mode for the Slack integration (#15605)
Earlier, every reply posted there was delivered to the customer. Teams
that use the channel for alerts had to remember a `note:` prefix on
every message, and a single slip reached the customer.

The integration now has a thread behavior setting. 2-way sync keeps the
current behavior and stays the default, so existing installs are
untouched. Alerts only makes the integration one way: conversations are
still pushed to Slack, but nothing posted in the thread is synced back,
leaving the thread free for internal discussion.
2026-08-27 17:23:31 -07:00
Sony Mathew
a4a9508854 feat(inboxes): display channel identifiers (#15528)
Inbox names now include their channel's public identifier everywhere
agents and administrators choose or configure an inbox, making similarly
named phone, email, website, and social channels easy to distinguish.

This PR is stacked on
[#15527](https://github.com/chatwoot/chatwoot/pull/15527). Review and
merge that contract PR first; this PR then contains only the visible UI
and search changes.

## Closes


[CW-7245](https://linear.app/chatwoot/issue/CW-7245/show-phone-numberchannel-identifier-consistently-in-inbox-ui)

## What changed

- Show `Inbox name · identifier` in compact sidebar channel rows without
changing their height or displacing unread/reauthorization badges.
- Show `Channel type · identifier` in the inbox settings list and use
the same identifier in the settings detail header.
- Search inbox settings by full public identifier in addition to inbox
name and channel type.
- Truncate long values, expose the full value on hover, and isolate
identifier direction for mixed RTL/LTR content.
- Preserve the existing UI whenever a channel has no identifier.

Implementation plan and channel mockups: [CW-7245 design
document](https://linear.app/chatwoot/document/cw-7245-channel-identifiers-in-inbox-ui-414ae118c964).

## How to test

1. Open the Channels section in the conversation sidebar and compare two
similarly named inboxes across phone, email, website, or social
channels.
2. Confirm each supported row shows its identifier on the same line and
unread/reauthorization badges remain visible.
3. Open Settings → Inboxes and confirm the channel type and identifier
appear beneath each inbox name.
4. Search by a complete phone number, email address, URL, social ID,
Telegram handle, LINE ID, or API inbox identifier.
5. Open an inbox's settings and confirm its header uses the same
identifier.
6. Check an inbox with a missing identifier and confirm the original
name-only/channel-only presentation remains.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-27 20:24:52 +05:30
Sojan Jose
ac1748519e fix: remove extra punctuation from participant copy (#15058)
The empty participant message no longer renders with both an exclamation
point and a trailing period.

Closes: [Crowdin editor
issue](https://chatwoot.crowdin.com/editor/2/all/en-it/24?view=comfortable&filter=basic&value=17)

## Why

The English source string ended with `!.`, which produced incorrect
punctuation and triggered a Crowdin source-string issue.

## What this change does

Removes the redundant trailing period from the no-participants message.

## Validation

Open a conversation with no participating agents and confirm the empty
state reads `No one is participating!`.
2026-08-27 07:48:02 -07:00
Sony Mathew
5726230525 feat: add Captain scenario and tool toggles (#15571)
## Description

Administrators can now enable or disable Captain scenarios and custom
tools directly from their cards, while agents retain read-only
visibility of each item's status. Disabled records remain available for
management, but are excluded from Captain prompts, handoff agents,
editor tool selection, and runtime execution as appropriate.

## Closes

-
[CW-7770](https://linear.app/chatwoot/issue/CW-7770/enable-or-disable-scenario-and-tools)

## What changed

- Return enabled and disabled scenarios from the management API so
disabled scenarios can be searched, edited, deleted, and re-enabled.
- Add localized status labels and administrator-only inline switches for
scenarios and custom tools, with per-record saving states and success or
failure feedback.
- Keep disabled custom tools valid as saved scenario references while
excluding them from runtime-enabled tool collections.
- Cover management visibility, toggle persistence, and disabled
custom-tool reference validation in request and model specs.

## Type of change

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

## How to test

1. As an administrator, open Captain scenarios and disable a scenario.
Confirm its status changes, remains visible, and stays disabled after
reload.
2. Re-enable the scenario and confirm it becomes available to Captain
again.
3. Open Captain custom tools and disable a custom tool referenced by a
scenario. Confirm the scenario remains editable and enabled while the
tool disappears from the scenario editor picker and Captain runtime
toolset.
4. Re-enable the custom tool and confirm it becomes available again.
5. Sign in as an agent and confirm scenario and custom-tool statuses are
visible without interactive switches.
6. Simulate a failed update request and confirm the previous state is
preserved with an error alert.

## 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 the feature works
- [x] New and existing focused tests pass locally with my changes
2026-08-27 18:17:34 +05:30
Aakash Bakhle
4d44847733 chore: remove Captain V2 rollout eligibility marker (#15599)
All eligible paid plans now enable Captain V2 during plan
reconciliation. The Hacker or configured default plan still disables V2.
Captain V1 runtime and the captain_integration_v2 feature flag remain in
place.

## Closes

No linked issue.

## How to test

1. Reconcile an existing or new cloud account on a Startups, Business,
or Enterprise plan.
2. Confirm that captain_integration and captain_integration_v2 are
enabled.
3. Reconcile an account on the Hacker plan.
4. Confirm that captain_integration_v2 is disabled.

## What changed

1. Removed the transitional captain_v2_default_eligible account
attribute and its new account write.
2. Enabled captain_integration_v2 for every non-default paid plan
without checking account attributes.
3. Kept the default plan and self-hosted Enterprise behavior unchanged.
4. Updated the focused account and billing reconciliation specs.
2026-08-27 16:56:33 +05:30
Sivin Varghese
57f0800006 chore: show exact date on timestamp hover (#15410) 2026-08-27 14:56:38 +05:30
Sony Mathew
4875ddc086 feat(inboxes): expose channel identifiers (#15527)
Inbox payloads now expose the safe public identifiers needed to
distinguish TikTok, X, and LINE inboxes, and the dashboard has one
canonical helper for resolving identifiers across every supported
channel.

## Closes

Part of
[CW-7245](https://linear.app/chatwoot/issue/CW-7245/show-phone-numberchannel-identifier-consistently-in-inbox-ui).

## What changed

- Expose TikTok `business_id`, X `profile_id`, and LINE
`line_channel_id` in the application inbox payload.
- Add a single identifier mapping for website, email, WhatsApp, Twilio,
SMS, Facebook, Instagram, TikTok, X, Telegram, LINE, and API inboxes.
- Normalize display-only Twilio WhatsApp and Telegram prefixes without
changing stored values.
- Document the safe identifier fields in the inbox API schema.

Implementation plan and channel mockups: [CW-7245 design
document](https://linear.app/chatwoot/document/cw-7245-channel-identifiers-in-inbox-ui-414ae118c964).

## How to test

1. Open inboxes for several channel types as an assigned agent.
2. Confirm the inbox API returns each channel's public identifier.
3. Confirm Twilio WhatsApp numbers omit the `whatsapp:` display prefix
and Twilio SMS falls back to its messaging service SID when no phone
number is configured.
4. Confirm missing identifiers resolve to an empty value and do not
expose channel credentials.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-27 14:55:31 +05:30
Sony Mathew
b18ddc0f1e Merge branch 'release/4.17.1' into develop 2026-08-27 14:44:55 +05:30
Sony Mathew
edada02806 Bump version to 4.17.1 2026-08-27 14:37:24 +05:30
Muhsin Keloth
4a63be9756 feat(whatsapp): make account health easier to understand (#15550)
The WhatsApp Account Health page currently exposes provider-oriented
values without enough context for customers to understand what they
mean. In particular, `AVAILABLE_WITHOUT_REVIEW` can sound like an
approved state even though Meta treats the display-name string and its
approval status separately, and starts display-name verification only
after the business reaches a higher messaging limit.

Tech Provider numbers also have customer-facing profile information in
Meta that is not visible in Chatwoot. Showing that read-only profile
alongside phone health gives admins one place to verify what customers
can see, reduces dependency on external diagnostic tools, and provides
better context before opening WhatsApp Manager.

Related:
https://linear.app/chatwoot/issue/PLA-199/manage-whatsapp-business-profile-fields-for-tech-provider-numbers

### What changed

- Fetch the current WhatsApp business profile from Meta together with
phone-number health.
- Show the profile image, About text, description, address, email, and
websites in Account Health.
- Hide the generic `OTHER` business category because it does not add
useful context.
- Present display-name states in customer language and explain what each
review state means.
- Rename the messaging tier to “Business messaging limit” and explain
its business-portfolio scope.
- Keep the profile lookup optional so a profile API failure does not
hide the existing phone and business-account health data.

### Things to know

- This is a read-only view; it does not edit the Meta business profile.
- Profile data is fetched when Account Health loads and is not stored in
`phone_number_health`.
- The profile picture URL is used to render the avatar but is not
exposed as raw text.
- Existing WhatsApp health persistence, transition warnings, and error
handling remain unchanged.

### How to test

1. Open **Settings → Inboxes** and select a direct WhatsApp Cloud API
inbox.
2. Open **Account Health** and confirm the Meta business profile appears
above the phone-number section.
3. Confirm missing optional profile values show **Not available** and a
profile lookup failure still leaves the core health sections usable.
4. For `AVAILABLE_WITHOUT_REVIEW`, confirm the page shows **Not reviewed
yet** with the explanation about Meta’s review process.
5. Confirm a profile category of `OTHER` is not shown, while specific
categories remain visible.
6. Confirm the messaging limit is labelled **Business messaging limit**
with its scope explained.

### Screenshots

Business profile details:

<img width="2530" height="1878" alt="CleanShot 2026-08-21 at 13 19
14@2x"
src="https://github.com/user-attachments/assets/5f29d9c8-5b82-4713-950d-f9112a79c123"
/>


Display-name guidance and health capacity:

<img width="2576" height="1860" alt="CleanShot 2026-08-21 at 13 19
38@2x"
src="https://github.com/user-attachments/assets/93866424-1a2a-46de-a8ec-0be937a57bc3"
/>

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-08-27 12:46:01 +04:00
William Bisimwa
99a25ab362 fix: automation rule content filter incorrectly splits on commas (#13785) (#14269)
Automation rules with a `content` condition do not match messages that
contain commas. The condition value is split on `,` before being sent to
the API, so a rule configured for `hello, world` is stored as two
separate tokens and never matches the original message.

Closes #13785

## What changed

`generateValues` in
`app/javascript/dashboard/helper/filterQueryGenerator.js` had a
special-case branch:

```js
if (item.attribute_key === 'content') {
  const values = item.values || '';
  return values.split(',');
}
```

This was already flagged with a `FIX ME: Make this generic option
instead of using the key directly here` comment in `generatePayload`.

The fix removes the branch entirely. String values already fall through
to the existing `return [item.values]` path, which produces the correct
single-element array. Arrays continue to flow through `setArrayValues`.
The stale `FIX ME` comment is also removed.

## Before / After

For an automation rule with content `hello, world`:

**Before**
```json
{ "values": ["hello", " world"] }
```

**After**
```json
{ "values": ["hello, world"] }
```

## Alternatives considered

- **Keep the special-case but escape commas (e.g. `\,`).** Rejected —
adds UX complexity (users would need to learn an escape syntax) and
there is no UI affordance for multi-value content conditions in the
first place. The split was solving a problem that doesn't actually exist
for this attribute.
- **Add server-side normalisation in
`AutomationRules::ConditionsFilterService`.** Considered as
defense-in-depth, but rejected to keep this PR scoped. The frontend is
the source of the malformed payload; the backend already (correctly)
expects an array. Adding server-side coercion here would conflate two
concerns.

## Cross-stack check

I read `app/services/automation_rules/conditions_filter_service.rb` and
`app/services/automation_rules/condition_validation_service.rb` to
confirm the backend contract. The service treats `values` as an array
throughout (e.g. `query_hash['values'].first` for label conditions,
array operators in SQL building), so producing a correct array on the
frontend is sufficient — no Rails change is needed.

## How to verify

Automated:

```bash
pnpm test app/javascript/dashboard/helper/specs/filterQueryGenerator.spec.js
```

All tests should pass, including the two new cases covering content with
and without commas.

Manual:

1. Create an automation rule: condition `Message → Content → equal_to →
hello, world`, action `Send Email`
2. Send an incoming message with body `hello, world`
3. Confirm the rule fires (before this change it would not, because the
stored condition was `["hello", " world"]`)

## Notes

- The same helper is consumed by `ChatList`, `ContactsIndex`, segment
exports and conversation getters. The fix transparently improves comma
handling in all of these — no consumer code changes were needed.
- No translation strings touched.

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-08-25 18:43:52 +05:30
Muhsin Keloth
aaf9848689 fix(instagram): restore business login authorization (#15575)
Meta deprecated part of the existing Instagram Business Login flow,
which could prevent customers from reaching the login page even when
their app configuration was correct. This hotfix updates the
authorization flow to use Metas current endpoint and reauthentication
parameter.

The callback URL, requested permissions, and token exchange remain
unchanged.

### How to reproduce

1. Configure an Instagram app with valid credentials and callback URL.
2. Start the Instagram inbox connection flow.
3. Observe that the previous authorization flow can show a page
unavailable error before login.

### How to test

1. As an administrator, start connecting a new Instagram inbox.
2. Confirm the browser opens the current Instagram Business Login page.
3. Complete authorization and confirm the callback returns to Chatwoot
successfully.

### Things to know

This is limited to the Instagram authorization endpoint and
reauthentication parameter.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-24 20:19:14 +04:00
Vishnu Narayanan
f35d2f83fb docs: document message read status APIs for API channels (#15469)
## Description

Customers building on the API channel could not find how to push message
read status into Chatwoot. Two endpoints are involved, one was entirely
missing from the API reference and one was undocumented in what it
actually does:

- `PATCH
/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages/{message_id}`
accepts `status` (`sent`/`delivered`/`read`/`failed`, plus
`external_error` for failures) and is restricted to API channel inboxes.
The path previously documented only DELETE; this adds the PATCH
operation.
- The public `update_last_seen` endpoint marks agent messages sent
before the contact's last seen time as read, but its description only
mentioned updating the timestamp, so searching the docs for read
receipts found nothing. The summary and description now state the
read-receipt behavior.

Derived swagger files regenerated via `rake swagger:build`.

Fixes https://linear.app/chatwoot/issue/CW-7937
2026-08-24 18:55:54 +05:30
jaroenchai_t
6a0bc5b6e4 fix(facebook): compare echo app_id as strings to detect Chatwoot-sent messages (#14963)
## Description

sent_from_chatwoot_app? compared the echo payload's app_id (a String
from the JSON webhook) against GlobalConfigService's FB_APP_ID cast with
`.to_i` (an Integer). String == Integer is always false in Ruby, so the
check always failed regardless of the actual app_id.

As a result, every Facebook Messenger echo of an agent's own Chatwoot
reply was treated as "agent replied from the native FB app" and
MessageCreator created a second, duplicate message row for it.

Compare app_id and FB_APP_ID as strings instead, and drop the two stale
TODO comments now that the behavior is confirmed and covered by a spec.

## Type of change

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


## 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: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-08-24 17:32:31 +05:30
Aditya Ganti
eef8f13b04 fix: reassign calls when merging contacts to prevent orphaned records (#15093)
Merging two contacts doesn't move the mergee's calls over to the base
contact. Conversations, messages, notes and contact inboxes all get
reassigned, but calls were missed when the Call model was added later. A
call requires a contact, so once the mergee is deleted its calls point
at a contact that no longer exists, and the account Calls dashboard 500s
as soon as it pages to one of them.

This adds the missing reassignment, plus a couple of guards so calls
that were already orphaned before this fix don't blow up either.

## Closes

Closes #15087

## How to reproduce

1. Create two contacts and add a call to the second one.
2. Merge the second contact into the first.
3. Open the Calls dashboard and page to where that call shows up.

You hit a 500 from `_call.json.jbuilder` (`undefined method 'id' for
nil`). Fixing only the backend then moves the crash to
`CallListItem.vue`, which reads `call.contact.avatar` without a null
check.

## What changed

- `ContactMergeAction` now reassigns calls. `Call` lives under
`enterprise/`, so OSS only has an empty `merge_calls` hook and
`Enterprise::ContactMergeAction` does the actual update.
- `_call.json.jbuilder` returns `contact: null` instead of raising when
the contact is gone.
- `CallListItem.vue` falls back to "Deleted contact" when `call.contact`
is missing.
- Added specs for the reassignment and for the null-contact API
response.

---------

Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
2026-08-24 16:40:29 +05:30
Vishnu Narayanan
389aca3187 perf: avoid per-membership queries in user auth payload (#15570)
# Pull Request Template

## Description

The user payload rendered by `api/v1/models/_user.json.jbuilder` (shared
by sign-in, token validation, and profile endpoints) iterates the user's
account memberships and previously issued one `accounts` lookup per
membership, one `custom_role` lookup per membership on enterprise, and
re-ran the unmemoized `active_account_user` ordering query three times
per render. For users belonging to many accounts this makes the auth
response cost linear in membership count and can push `/auth/sign_in`
and `/auth/validate_token` past the request timeout.

This change preloads the memberships once (`includes(:account)`, plus
`:custom_role` on enterprise) and derives the active account in memory
from the preloaded rows, keeping full timestamp precision and `NULLS
LAST` semantics.

Measured by counting SQL queries per request at increasing membership
counts:

| Memberships | `GET /auth/validate_token` on develop | with this change
|
|---|---|---|
| 1 | 10 | 9 |
| 10 | 19 | 9 |
| 100 | 109 | 9 |
| 500 | 509 | 9 |

`POST /auth/sign_in` drops from 2016 to 1516 queries at 500 memberships;
its remaining growth comes from the per-account audit writes addressed
in #15572. With both changes applied, sign-in settles at 19 queries and
token validation at 9, independent of membership count.

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

## Type of change

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

## How Has This Been Tested?

- Tested locally with users in 1, 10, 100, and 500 accounts; the query
counts above are from those runs.
- New request spec asserts the SQL query count for
`/auth/validate_token` does not grow with the number of account
memberships (fails on develop, passes with this change).
- Existing specs covering all render sites of the partial (devise
sessions/token validations, profiles, enterprise profiles) pass locally.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-24 16:01:00 +05:30
Vishnu Narayanan
02ae3fed6b perf: batch sign-in and sign-out audit inserts (#15572)
# Pull Request Template

## Description

Enterprise sign-in and sign-out create one audit event per account the
user belongs to. Each of those went through a separate `create`, costing
three queries per account (a version lookup, the insert, and a username
update from the after_save hook). For users belonging to many accounts
this makes authentication cost scale linearly with membership count and
contributes to request timeouts.

This change writes the same audit rows with a single bulk insert. Row
contents are unchanged: sequential versions continue from the current
max, username and user attribution are identical, and all rows share the
request uuid and remote address as before.

Measured by counting SQL queries per request at increasing membership
counts:

| Memberships | `POST /auth/sign_in` on develop | with this change |
|---|---|---|
| 1 | 21 | 21 |
| 10 | 56 | 29 |
| 100 | 416 | 119 |
| 500 | 2016 | 519 |

The remaining growth is the response rendering addressed in #15570. With
both changes applied, sign-in settles at 19 queries and token validation
at 9, independent of membership count.

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

## Type of change

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

## How Has This Been Tested?

- Tested locally with users in 1, 10, 100, and 500 accounts; the query
counts above are from those runs.
- New request spec: sign-in for a user in multiple accounts creates one
audit per account using a single insert statement, with sequential
versions, correct association fields, username, and a shared request
uuid.
- Existing enterprise session specs (single account sign-in, sign-out,
SAML, invalid credentials) pass unchanged: 7 examples, 0 failures.
- Old and new attribution compared empirically on develop: rows produced
by the previous per-account loop match the batched rows column for
column.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-24 16:00:17 +05:30
Shivam Mishra
a8efc71f42 feat(captain): redesign assistant overview dashboard (#15516)
This PR introduces a redesigned Captain Overview behind the localStorage
flag `captain_overview_v2: true`. It expands the Captain layout and adds
a rotating AI summary, key performance metrics, conversation resolution
flow with handoff reasons, weekly resolution trends, Captain CSAT,
usage, and knowledge coverage.

The resolution trend can switch between conversation counts and
current-versus-previous resolution rates using the same reporting
granularity. Metrics include concise, plain-language explanations, and
the supporting outcome reporting and seed data are updated accordingly.

Enable it using the following command in console, and reload the page
```js
 localStorage.setItem('captain_overview_v2', 'true');
```

<img width="2880" height="2896"
alt="localhost_3000_app_accounts_1_captain_18_overview"
src="https://github.com/user-attachments/assets/a10f4f0a-1392-4432-9cd4-667d236178fa"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-08-24 15:55:00 +05:30
Shivam Mishra
f3f06b902a feat(perf): reduce sdk.js bundle size (#15381)
This PR reduces the web SDK's gzipped bundle size by about 30%, lowering
the amount of JavaScript downloaded and parsed on uncached SDK loads. It
also serves precompressed Brotli or gzip from the existing SDK URL and
adds repeatable bundle-analysis tooling to help catch future size
regressions.

## What changed

### Use a lightweight synchronous user fingerprint

The SDK used MD5 only as a local fingerprint for detecting whether
`setUser` data had changed. It was not used as a conversation
identifier, authentication mechanism, or part of the server API
contract. This PR replaces the MD5 dependency with a small inline
128-bit [FNV-1a](https://www.rfc-editor.org/rfc/rfc9923.html)
implementation.

FNV-1a is synchronous and works in both secure and insecure embedding
pages. This preserves the previous latest-call-wins behavior when
integrations call `setUser` repeatedly, without relying on
`SubtleCrypto`, which is unavailable on plain HTTP origins. The 128-bit
output prevents practical 32-bit collisions from causing distinct
identities to be treated as unchanged; regression coverage includes a
known pair of identifiers that collide under FNV-1a 32-bit. Existing
hash cookies produce a one-time mismatch, causing `setUser` to be sent
once before the cookie is replaced with the new fingerprint.

The ESLint environment now declares ES2020, matching the SDK's Vite
build target and allowing the implementation to use `BigInt` directly.

The SDK also used `date-fns` only to add one hour to the current time.
Native timestamp arithmetic provides the same behavior for this fixed
duration without bundling the associated date utilities.

### Minify inline SDK styles

SDK styles now pass through [Vite's inline CSS
pipeline](https://v6.vite.dev/guide/features#disabling-css-injection-into-the-page).
Vite minifies the stylesheet at build time and returns it as a string,
while the SDK continues injecting it into the embedding page as before.

### Serve precompressed bundles from the same URL

The SDK build now emits Brotli and gzip sidecars. Rails 7.2
[automatically negotiates precompressed
assets](https://api.rubyonrails.org/v7.2.3.1/classes/ActionDispatch/FileHandler.html)
and serves the best format supported by the browser from the existing
`/packs/js/sdk.js` URL. No embed-code or application URL changes are
required.

## Bundle size

| Metric | Before | After | Reduction |
|---|---:|---:|---:|
| Raw | 29.20 kB | 21.75 kB | 25.5% |
| Gzip | 9.73 kB | 6.89 kB | 29.2% |
| Brotli | — | 6.08 kB | — |

## How to test

1. Load the widget from `/packs/js/sdk.js` in a browser that supports
Brotli or gzip and confirm the same URL is returned with the
corresponding `Content-Encoding` header.
2. Embed the widget on HTTPS and plain HTTP pages, call
`$chatwoot.setUser`, and confirm the user is identified in both
contexts.
3. Call `setUser` several times consecutively with different identities
and confirm the final call remains the active widget identity.
4. Reload the embedding page with unchanged user data and confirm the
SDK does not send a redundant `setUser` update.
5. Confirm the widget layout and styles remain unchanged.

## Staging test

#### Before

<img width="2192" height="710" alt="CleanShot 2026-08-12 at 16 23 27@2x"
src="https://github.com/user-attachments/assets/b60cf6dc-bb12-460c-9bed-d8720010ccf6"
/>

#### After

<img width="2192" height="1014" alt="CleanShot 2026-08-12 at 16 57
53@2x"
src="https://github.com/user-attachments/assets/4ce97fab-35a8-44cc-8add-20a0d59d5755"
/>
2026-08-24 14:39:36 +05:30
Sojan Jose
6154aebcfe fix(captain): set cloud mode in reply suggestion specs (#15560)
Ensures Captain reply-suggestion usage tests run in cloud mode, matching
the production guard introduced by #15465.

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

## Why

Captain response usage is now intentionally disabled for self-hosted
installations, but three cloud usage expectations were not updated. This
left the backend test suite on `develop` failing.

## What this change does

Runs the reply-suggestion usage scenarios with Chatwoot Cloud enabled,
consistent with the existing Copilot chat coverage.

## Validation

- Reproduced all three failures on a clean `develop` checkout before the
change.
- All 18 reply-suggestion scenarios pass after the change.
- Ruby lint passes.
2026-08-22 13:03:18 +04:00
Aakash Bakhle
ed230f9bc0 fix: skip self-hosted Captain usage tracking (#15465)
Captain response usage is a Chatwoot Cloud billing counter. Self hosted
installations use their own model credentials, so their responses should
not increase this counter.

## Root cause

`Account#increment_response_usage` increased `captain_responses_usage`
on every installation.

Self hosted installations do not receive the Cloud billing reset. Their
stored usage could therefore keep growing even though they do not buy
model credits from Chatwoot.

## What changed

`increment_response_usage` now returns without changing the counter
unless the installation is Chatwoot Cloud.

No credit limits, routing rules, pricing plan checks, Community
behavior, document behavior, crawling behavior, or frontend behavior
changed.

Existing self hosted accounts with a high stored counter need a one time
counter reset after deployment. Future self hosted responses will not
increase it again.

## Checks

The focused backend suite passed 176 examples.

RuboCop inspected 6 files and found no offenses. `git diff --check`
passed.
2026-08-21 13:12:09 +05:30
Sojan Jose
6b24cf8804 refactor: add unified AI assignee association (#15419)
Adds the typed AI-assignee foundation and makes the polymorphic
association authoritative for existing AgentBot ownership writes, while
preserving current conversation behavior.

## Closes

- Part of
https://linear.app/chatwoot/issue/CW-7451/add-explicit-captain-ownership-backend

## Why

AgentBot and Captain ownership should share one typed association
instead of accumulating provider-specific ownership columns and write
paths. This establishes that common ownership surface before Captain
becomes assignable.

## What changed

- Adds the AI owner type discriminator and backfills existing
AgentBot-owned conversations.
- Adds the polymorphic `ai_assignee` association using the existing
AgentBot owner ID column.
- Routes AgentBot assignment, handoff, human takeover, automatic inbox
assignment, and ownership clearing through the typed association.
- Keeps existing AgentBot reads, API responses, queues, and user-facing
behavior unchanged.

Typed reads, inverse cleanup, event notifications, delayed rollout
reconciliation, and Captain assignment behavior follow in stacked PR
#15421.

## Validation

- Assign an AgentBot and confirm the conversation becomes pending with
AgentBot ownership.
- Assign a human or take over the conversation and confirm AI ownership
clears while the conversation opens when applicable.
- Unassign the conversation and confirm its status is not changed
implicitly.
2026-08-21 00:07:29 -07:00
Muhsin Keloth
b49359698a fix(dev): keep tunnel vite watcher running (#15539)
Starting the tunnel workflow could immediately stop Rails and Sidekiq
when Vite Ruby found the frontend build already up to date. This keeps
the Vite build watcher alive so the Overmind tunnel stack remains
available.

### How to reproduce

1. Ensure the Vite build is already current.
2. Run `make force_run_tunnel`.
3. Observe Vite skip the build and exit, which causes Overmind to stop
the remaining processes.

### Things to know

The `--force` option is handled by Vite Ruby to bypass its freshness
check. Vite still runs in build watch mode, so no development server
port is exposed.

### How to test

1. Run `make force_run_tunnel` with an existing frontend build.
2. Confirm Vite completes the build and continues watching for changes.
3. Open the configured tunnel URL and confirm the Chatwoot login page
renders while Rails and Sidekiq remain running.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-20 19:02:07 +05:30
Shivam Mishra
3fe2b4361b fix(sdk): refresh users when contact details change (#15538)
Updates the web widget user fingerprint to include contact information
supported by setUser. Existing contacts now sync when phone, company,
location, description, or social profiles change, while custom
attributes remain handled separately.

## Closes

Closes #13448

## How to reproduce

1. Identify a contact with setUser.
2. Change only phone_number, country_code, city, company_name,
description, or social_profiles.
3. Call setUser again and confirm the existing contact is updated.
2026-08-20 15:53:14 +05:30
Aakash Bakhle
41ce45bd83 fix: include conversation labels in Captain state (#15531)
## Summary

Captain now reads the conversation label list through the model accessor
when it builds runner state. The regression coverage reloads a labeled
conversation before building state, which matches the background job
path.

## Why

`label_list` is a virtual attribute backed by conversation tags. The
runner previously sliced it from `record.attributes`, where it is nil on
a persisted and reloaded conversation. Captain therefore omitted active
conversation labels from the model prompt even though the labels were
present on the conversation.

The change keeps the existing runner state shape and ensures the current
label list reaches the prompt.

## Testing

- `bundle exec rspec
spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb`
- `bundle exec rubocop --cache-root /tmp/codex-rubocop-aa05
enterprise/app/services/captain/assistant/runner_state_helper.rb
spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb`
2026-08-20 12:55:52 +05:30
Aakash Bakhle
2dce6c03f1 refactor: generate Captain reasoning before responses (#15532)
## Summary

Captain now places `reasoning` before `response_parts` in its structured
response schema. The focused test checks the property order so a later
refactor cannot silently reverse it.

## Why

OpenAI Structured Outputs generates fields in the order declared by the
schema. Captain previously generated the customer response before
generating the reasoning that was meant to support it.

A controlled GPT-5.2 replay of a production language adherence failure
returned German in three of three runs with the current response-first
schema. With the same prompt and a reasoning-first schema, it returned
English in two of two runs. The sample is small, so this change is an
adherence improvement rather than deterministic enforcement.

OpenAI also documents reasoning before the final answer as a way to
improve final response quality:
https://openai.com/index/introducing-structured-outputs-in-the-api/#separating-a-final-answer-from-supporting-reasoning-or-additional-commentary

## Testing

- `bundle exec rspec spec/enterprise/models/concerns/agentable_spec.rb`
- `bundle exec rubocop --cache-root /tmp/codex-rubocop-aa05
enterprise/lib/captain/response_schema.rb
spec/enterprise/models/concerns/agentable_spec.rb`
2026-08-20 12:55:36 +05:30
Sivin Varghese
3eab288d99 chore: clean up resolved conversation guard in widget (#15535) 2026-08-20 09:04:34 +05:30
Sojan Jose
9486c5d795 Merge branch 'release/4.17.0' into develop 2026-08-19 16:31:58 -07:00
Sojan Jose
c2f98d85cb Bump version to 4.17.0 2026-08-19 16:22:40 -07:00
Sivin Varghese
1780e8f701 fix: prevent reopening resolved conversations from widget (#15529) 2026-08-19 20:53:13 +05:30
Muhsin Keloth
5157407a92 fix(security): render identity labels as plain text (#15525)
Agent and company names could be interpreted as sanitized HTML in
conversation activity and widget identity labels. These fields are
plain-text identity data, so rendering markup can alter trusted UI and
trigger external resources.

This change renders activity content and widget identity labels through
Vue text interpolation. Rich message-body formatting remains unchanged.

Fixes
https://linear.app/chatwoot/issue/CW-7979/stored-html-injection-via-agent-profile-name-in-conversation-activity

### Things to know

Existing stored markup is neutralized at render time. This does not
change how regular message content is formatted.

### How to test

1. Set an agent name to text containing HTML-like markup.
2. Resolve or reopen a conversation and confirm the activity entry
displays the markup literally without creating an element or loading an
image.
3. Send a message to the web widget and confirm the agent and company
labels display as plain text.
4. Confirm regular message-body formatting still works.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-19 15:25:35 +04:00
Sivin Varghese
c11c1b7058 fix: dropdown clipping and condition label in automation rules (#15503) 2026-08-19 16:36:40 +05:30
Sony Mathew
9849d74e70 fix: localize friendly email sender names (#15459)
# Pull Request Template

## Description

Friendly sender names in outgoing emails now follow the sending user's
selected profile language. When the sender is not a user or has no
explicit locale preference, the sender name falls back to the account
locale.

This keeps the delivered sender name consistent with the localized
preview without changing the locale used for the email subject or body.
Both the legacy reply mailer and the migrated email builder path use the
same locale precedence.

### Closes


https://linear.app/chatwoot/issue/CW-7940/friendly-sender-name-shows-english-format-instead-of-selected-locale
2026-08-19 15:06:47 +05:30
Vishnu Narayanan
ac37bbcb87 fix: keep super admin user action buttons on one row (#15515)
## Description

On the super admin user show page, the actions area renders an Edit link
next to a Resend confirmation email button. The resend control is a
`button_to`, which Rails wraps in a block-level `<form>`. That form
dropped onto its own line instead of sitting beside Edit, stretching the
header and pushing the page content down. It only renders for
unconfirmed users, so the confirmed-user view looked fine and the
regression was easy to miss.

This makes the actions container a flex row and takes the form out of
the box flow (`display: contents`) so its button aligns inline next to
Edit. No behavior change.

Related to https://linear.app/chatwoot/issue/CW-7527

## Type of change

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

## How Has This Been Tested?

Verified locally on the super admin user show page for an unconfirmed
user (Edit + Resend now inline) and a confirmed user (Edit only,
unchanged).
2026-08-19 14:37:42 +05:30
Pranav
b8118784cb fix: Read API version from installation config (#14937)
Use InstallationConfig for Instagram API version instead of a hardcoded
version number.

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-08-19 12:52:16 +04:00
Tanmay Deep Sharma
6ca3423c2e fix(conversations): emit conversation.opened when legacy auto assignment runs on the open transition (#15509)
When a conversation transitioned to Open on an inbox using legacy (V1)
auto-assignment, the assignment write ran as a second save on the same
conversation inside the same transaction. That second save replaced
`saved_changes`, so the commit callbacks only saw the assignee change:
`conversation.opened` and `conversation.status_changed` were never
emitted — no "Conversation opened" activity message, no
`conversation_opened` reporting event, and automations triggered on
Conversation Opened silently never ran. Since this happened exactly when
auto-assignment succeeded, it was the dominant path on V1 inboxes, not
an edge case (in a concurrency test, 58 of 60 open transitions lost the
event). The most visible instance: an agent replying to a Captain
Pending conversation, which opens it — but the same loss happened from
the UI open button or any other open transition.

## Closes

-
[CW-7967](https://linear.app/chatwoot/issue/CW-7967/conversation-opened-event-not-emitted)

## How to reproduce

1. On an inbox with auto-assignment enabled and Assignment V2 off, have
at least one available agent.
2. Take an unassigned Pending conversation (e.g. one Captain is
handling) and send a public agent reply, or open it via the status
toggle.
3. The agent gets assigned, but the conversation shows no "Conversation
opened" activity, reporting records no `conversation_opened` event, and
Conversation Opened automations don't fire.

## What changed

- Legacy (V1) auto-assignment for status transitions now runs in a
`before_save` and sets the assignee on the in-flight save, so status and
assignee commit as a single change-set and both stay visible to the
`after_commit` callbacks. Rails only surfaces one change-set per row per
transaction to commit callbacks, so a follow-up save can never preserve
both — folding the assignment into the same UPDATE is the shape that
fixes this without touching the event layer.
- The lock-then-recheck semantics from #15275 are preserved and now
genuinely serialize: `AgentAssignmentService#assign_under_lock` takes
the row lock, re-checks `reassignment_still_needed?` against committed
state, then sets the assignee — with the lock now acquired before our
own write (previously the status UPDATE had already locked the row
before the service's lock ran). Verified under concurrent load: opens
racing manual assignment writes produce exactly one assignment write, no
assignee bouncing, and no deadlocks, matching current behavior.
- New conversations keep the post-save assignment path (`perform`, now
built on `assign_under_lock`): their status is only finalized by
`before_create` callbacks, which run after `before_save`, and creates
don't emit `conversation.opened` in the first place.
- Adds a regression spec asserting the open transition with V1
auto-assignment dispatches both `conversation.opened` and
`assignee.changed`, with `status` and `assignee_id` in the same set of
committed changes.
2026-08-19 14:00:36 +05:30
Vishnu Narayanan
2d173978fc fix(security): scope conversation source_id lookup to the current account (#15521)
## Description

The source-only fallback in
`Api::V1::Accounts::ConversationsController#contact_inbox`
resolved a `ContactInbox` globally by `source_id`, unlike the sibling
lookups in the
controller which scope through an inbox or the current account. This
scopes the fallback
to the current account's inboxes (and to the supplied inbox when
`inbox_id` is present),
keeping it consistent with the rest of the controller and deterministic
when the same
`source_id` exists in more than one inbox.

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

## Type of change

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

## How Has This Been Tested?

Added request specs for bot-authenticated conversation creation: a
create for a
`source_id` that belongs to another account no longer resolves (returns
404), and a
create for a `source_id` in the bot's own account still succeeds. Full
conversations_controller_spec passes (93 examples, 0 failures); RuboCop
clean.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
2026-08-19 13:41:42 +05:30
Vishnu Narayanan
02593b6864 chore: bump mail gem to 2.9.1 (#15522)
## Description

Bumps the `mail` gem from 2.8.1 to 2.9.1 to resolve
[GHSA-mvxr-6m87-mv2q](https://github.com/mikel/mail/security/advisories/GHSA-mvxr-6m87-mv2q)
(email address spoofing via malformed RFC 2047 encoded-words), which is
currently failing the bundle-audit CI check on develop ([example failing
run](https://github.com/chatwoot/chatwoot/actions/runs/32226134705/job/95986093102?pr=15521)).

`mail` is a transitive dependency (via actionmailer), so this is a
lockfile-only change done with `bundle update mail --conservative`. The
only dependency change is the new explicit `logger` dependency, which is
already in the bundle.

## Type of change

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

## How Has This Been Tested?

- `bundle exec bundle-audit check --update` passes (no vulnerabilities
found)
- Verified mail 2.9.1 loads and renders a basic message correctly
- All email specs pass

## 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-08-19 13:05:36 +05:30
Vishnu Narayanan
8d93d69e8e feat: audit log filtering, search and sort (#15479)
## Description

Adds filtering, search and sorting to the audit logs page. The endpoint
previously supported only pagination, so finding a specific event meant
paging through everything.

- Keyword search by the acting user name or email: auto-searches 800ms
after typing pauses (3+ characters), enter searches immediately; stale
responses are discarded
- Event type filter, grouped by category; only event types that are
actually logged are offered
- Date range filter with presets and custom range, applied only on an
explicit choice
- Sort by date (newest or oldest first)

Filters live in the URL query params so filtered views are shareable and
survive refresh. All params are optional, so existing clients are
unaffected. A composite index on `audits (associated_type,
associated_id, created_at)` supports account-scoped filtering and
sorting; it is created concurrently.

Two small shared-component changes were needed:

- `DatePicker` gains a `defaultOpen` prop and now clamps its calendar
popup within the viewport (triggers that sit mid-row pushed the 880px
popup off screen). Reports pages keep their existing behavior.
- `SettingsWrapper` keys routed pages by `route.path` instead of
`route.fullPath` when a route opts in via `meta.reuseOnQueryChange`, so
query-param filter changes no longer remount the page (which stole input
focus and refetched on every change). Only the audit logs route opts in.

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

## Type of change

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

## How Has This Been Tested?

- Request specs covering each filter, combinations, sorting, and
malformed params
- Vitest specs for the API client, store action, route query helpers,
and the filter bar component
- Manual pass on a dev instance seeded with 1000+ audit rows across all
event types, including narrow-viewport checks

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-19 12:44:55 +05:30
Muhsin Keloth
41051ad734 fix(campaigns): clarify pending whatsapp campaign statuses (#15518)
WhatsApp campaign analytics currently groups queued recipients and
messages awaiting a WhatsApp delivery update under “Pending,” which
makes it difficult to understand whether those messages have been
submitted.

This change makes the processing state explicit. The summary now uses
“Submitted to WhatsApp,” sent recipients are labeled “Awaiting delivery
update,” and the processing banner shows the live queued and
awaiting-update counts.

Related:
https://linear.app/chatwoot/issue/CW-4627/ee-basic-analytics-for-whatsapp-campaigns

### Things to know

- This only changes the analytics presentation; delivery states and API
calculations remain unchanged.
- The banner continues to refresh with the existing analytics polling.
- Singular, queued-only, awaiting-only, initial-loading, and completing
states have dedicated copy.

### How to test

1. Open analytics for a processing WhatsApp campaign with queued and
sent recipients.
2. Confirm the banner shows both counts: “X messages are queued, and Y
are awaiting a delivery update from WhatsApp.”
3. Confirm the summary says “Submitted to WhatsApp,” the sent filter
says “Awaiting delivery update,” and the breakdown says “Queued or
awaiting update.”
4. Confirm the banner adapts when either count reaches zero and updates
as campaign statuses change.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-19 10:57:30 +04:00
Shivam Mishra
25da7dc6f0 chore: migrate pico-search package (#15514)
Updates Chatwoot to use the newly migrated `@chatwoot/pico-search`
package at version `0.6.2`. This follows the repository ownership move
and picks up the latest published bug fix without changing how search is
called in the application.

## What changed

- Replaced `@scmmishra/pico-search` with `@chatwoot/pico-search`.
- Updated all application imports to the new package scope.
- Refreshed the pnpm lockfile for version `0.6.2`.

Validation: `pnpm install --frozen-lockfile`, staged ESLint, and the
repository pre-push validation completed successfully.
2026-08-18 19:01:22 +05:30
Aakash Bakhle
69f3238d89 fix(captain): apply V2 assistant behavior to Copilot reply suggestions (#15342)
## Summary

Copilot's **Suggest a reply** action now uses the selected Captain
Assistant's knowledge and instructions. Support agents can review and
edit the generated reply before sending it to the customer.

The action uses the same FAQ knowledge, response guidelines, guardrails,
citation settings, and conversation context as the selected Assistant.
Regular Copilot conversations keep their existing behavior.

## Scope

The new behavior applies only when a support agent clicks **Suggest a
reply** in Copilot.

The reply can use:

* The selected Assistant's description, response guidelines, and
guardrails.
* The full public conversation history up to the latest customer
message.
* The Assistant's FAQs.
* Custom tools that use GET requests.
* Raw links from FAQs, response guidelines, and generated answers.
* Citations when citations are enabled for the Assistant.

The action uses one Captain credit after a reply is generated and saved.
If the public conversation changes while Copilot is drafting, the reply
is discarded and no credit is used.

## Not included

* Assistant scenarios.
* Human handoff, conversation resolution, private notes, or other
actions that change conversation data.
* Custom tools that use POST requests.
* Prompts typed into Copilot, including prompts such as "suggest a
reply."
* Changes to the Assistant Playground, the customer facing Assistant, or
regular Copilot conversations.


[AI-195](https://linear.app/chatwoot/issue/AI-195/copilot-suggest-a-reply-should-read-assistant-instructions)
2026-08-18 18:22:25 +05:30
Aakash Bakhle
59442e5762 feat: support internal feature model overrides (#15460)
## Description

PR #15317 moved conversation completion to FeatureRouter as an internal
feature. Internal features were then missing from Super Admin model
routing, and FeatureRouter ignored any internal account override.

The change adds separate Customer features and Internal features
sections to the Super Admin account show and edit pages. It allows known
internal model keys in account settings and makes valid account
overrides work for internal features. Customer Captain preferences still
hide internal features.

Conversation completion now uses the same model list as the Assistant
feature. A per-account override takes priority over
CAPTAIN_OPEN_AI_MODEL on self-hosted Enterprise. The installation model
still takes priority over the YAML default when no account override
exists.

Follow-up to #15317.

<img width="738" height="414" alt="CleanShot 2026-08-13 at 16 42 01@2x"
src="https://github.com/user-attachments/assets/08444805-1a0e-4085-a5fc-b3e60e2e2597"
/>


## Type of change

- [x] New feature
- [ ] Bug fix
- [ ] Breaking change
- [ ] Documentation update

## How has this been tested?

- Ran the FeatureRouter, Super Admin account, conversation completion
service, model catalog, account validation, and customer Captain
preferences specs.
- Ran RuboCop on the changed Ruby files.
- Checked the changed ERB, Ruby, YAML, and Git diff syntax.
- Used Playwright against the Cook UI to expand the account routing
panel and confirm the Conversation completion card appears under
Internal features.
- Inspected the Playwright screenshot for layout, clipping, and readable
selected values.

## Checklist

- [x] My code follows the project style guidelines.
- [x] I reviewed the complete diff.
- [x] I added tests that prove the change works.
- [x] New and existing focused tests pass locally.
- [x] Internal features remain hidden from customer Captain preferences.
2026-08-18 17:45:15 +05:30
Vishnu Narayanan
61a03ef0aa feat: publish Sidekiq queue metrics to CloudWatch for autoscaling (#15461)
## Description

Adds optional publishing of Sidekiq queue metrics (queue latency, queue
size, enqueued jobs, worker utilization) to AWS CloudWatch, so
background-worker capacity can be autoscaled on queue backlog rather
than CPU/memory, which are weak signals for I/O-bound job workloads.

Uses the `speedshop-cloudwatch` gem. It is off by default and opt-in via
`ENABLE_SIDEKIQ_CLOUDWATCH`. The gem is loaded with `require: false` and
wired only inside the Sidekiq server lifecycle, so web and other
processes are unaffected (no Rack middleware is inserted). Namespace and
interval are configurable.

Credentials: by default it uses the EC2 instance role, which must allow
`cloudwatch:PutMetricData`. This avoids picking up the shared
`AWS_ACCESS_KEY_ID` used for object storage, which is not scoped for
CloudWatch. Dedicated credentials can be supplied via
`SIDEKIQ_CLOUDWATCH_AWS_ACCESS_KEY_ID` /
`SIDEKIQ_CLOUDWATCH_AWS_SECRET_ACCESS_KEY` /
`SIDEKIQ_CLOUDWATCH_AWS_REGION` to override the instance role where one
is not available.

This supersedes the earlier #12087, which had the same goal with a
different gem. This one is purpose-built for autoscaling and reports on
a background thread.

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

## Type of change

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

## How Has This Been Tested?

Verified end to end on a staging environment:

- Wiring: with the flag enabled, config resolves to the
`Chatwoot/Sidekiq` namespace, only the allow-listed metrics publish,
per-queue `QueueLatency`/`QueueSize` are emitted with a `QueueName`
dimension, and no Rack middleware or Puma collector is loaded (Sidekiq
only). Rubocop clean.
- Live at idle: deployed with `ENABLE_SIDEKIQ_CLOUDWATCH=true`; metrics
published every 60s to CloudWatch via the instance role (no dedicated
keys needed).
- Under load: enqueued ~110k jobs to an unconsumed test queue;
`EnqueuedJobs` and per-queue depth/latency rose accordingly, then
returned to baseline once the queue was cleared.
- Autoscaling usability: the published metric drove an ASG
target-tracking policy that scaled the worker fleet out under backlog
and back in once drained.
2026-08-18 16:38:57 +05:30
Muhsin Keloth
7705be5f3c fix(twilio): safely retry redirected media downloads (#15504)
Twilio can briefly return a 404 for valid inbound WhatsApp media while
the asset propagates. Chatwoot currently skips that attachment, so the
conversation keeps its text but permanently loses the image or file.

This change adds bounded authenticated retries for verified Twilio media
URLs and safely follows Twilio's normal redirect to its signed CDN.
Credentials are restricted to the original trusted Twilio request and
are stripped when the download crosses origins.

### How to reproduce

1. Receive a Twilio WhatsApp message whose media endpoint returns a
transient 404.
2. Observe that the current flow skips the attachment immediately.
3. A previous attempt to disable redirects avoided credential forwarding
but also blocked Twilio's expected secure-CDN redirect, causing valid
downloads to fail.

### What changed

- Validate the Twilio API host, account SID, message SID, and media SID
before sending Basic Auth.
- Retry authenticated 404 responses after 1 and 3 seconds, sharing one
retry-delay budget across all attachments in the message.
- Download through `SafeFetch`, which follows safe redirects and removes
authorization across origins.
- Resolve Twilio media through IPv4 so dual-stack CDN DNS cannot
randomly select an unreachable IPv6 route.
- Keep non-Twilio media downloads anonymous.
- Preserve the inbound text when media retries are exhausted and emit
structured download outcome logs.

### Things to know

- Retry applies only to verified Twilio media URLs and only to 404
responses.
- The maximum added wait per inbound message is four seconds, regardless
of attachment count.
- Other download failures retain the existing skip-attachment behavior.

### How to test

1. Send an inbound Twilio WhatsApp message with an image to a Chatwoot
inbox.
2. Confirm the text and image appear in the conversation after Twilio
redirects the media request to its signed CDN.
3. Simulate a media endpoint that returns 404 once and then succeeds;
confirm the attachment appears after retry.
4. Simulate persistent 404 responses; confirm the text message is
created without the attachment after the bounded retries.
5. Send a message containing a non-Twilio media URL; confirm it
downloads without Twilio credentials.

Fixes
https://linear.app/chatwoot/issue/PLA-206/retry-transient-twilio-whatsapp-media-404s-before-skipping-attachments

Related: #15499
Related: #15502

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-18 14:19:45 +04:00
Matt Van Horn
90acfa10fa fix: Surface inline-marked email images as regular attachments when their CID is not referenced in the HTML body (#14774)
## Summary
When an inbound email (e.g. Apple Mail with
DisableInlineAttachmentViewing set) marks an image part as
Content-Disposition: inline with a Content-Id but does NOT reference
that CID via cid: in the HTML body (instead emitting Apple's proprietary
<attachment> tag), the image is silently dropped.

Fixes #14633

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-08-18 15:01:24 +05:30
Shivam Mishra
df9e5c4548 fix: treat empty labels as absent in filters (#15510)
Fixes an issue where filtering conversations using **Labels → Is not
present** caused the conversation list to remain empty and repeatedly
reload. This affected both the Conversations view and saved folders,
preventing teams from reliably finding unlabeled conversations for
supervision and quality assurance.

## Closes

-
[CW-7861](https://linear.app/chatwoot/issue/CW-7861/bug-labels-is-not-present-filter-triggers-an-infinite-flickering)

## How to reproduce

1. Open the Conversations page.
2. Open advanced filters.
3. Select **Labels**.
4. Select **Is not present**.
5. Apply the filter.
6. Observe that no conversations are displayed and the conversation list
repeatedly flickers while loading additional pages.

The same behavior can be reproduced by creating or opening a folder that
uses this filter.

## What caused the issue

The backend correctly returned unlabeled conversations with `labels:
[]`. The frontend then reapplied the filter locally but only treated
`null` and `undefined` as absent.

As a result, an empty labels array was incorrectly considered present,
and every conversation returned by the backend was removed from the
displayed list. The empty list kept the infinite-scroll observer
visible, causing it to request additional pages repeatedly and produce
the flickering reload loop.

## What changed

The frontend filter matcher now treats an empty labels array as absent
when evaluating label presence operators. This aligns frontend filtering
with the backend:

- `labels: []` matches **Is not present**
- A non-empty labels array matches **Is present**

The change is scoped specifically to labels, preserving the existing
presence semantics for other attributes and custom fields.
2026-08-18 14:40:45 +05:30
Vishnu Narayanan
20828cce90 feat: add resend confirmation email action in super admin (#15383)
## Description

Adds a "Resend confirmation email" button to the super admin user detail
page. When an unconfirmed user's confirmation email fails to reach them,
an admin can re-trigger it from the panel instead of using the Rails
console. The action sends the standard Devise confirmation email to the
user's own registered address, and is only available while the user is
unconfirmed. No confirmation token or link is exposed in the UI.

Related to https://linear.app/chatwoot/issue/CW-7527
2026-08-18 14:00:54 +05:30
Shivam Mishra
725df66140 feat: expose Captain outcome statistics (#15481)
Captain outcome reporting is now available through dedicated assistant
stats endpoints. The new overview summary analyzes outcome,
resolution-flow, and trend data to surface up to three specific,
evidence-backed insights using rounded approximations suited to an
hourly cached summary.

## Closes

Follow-up to https://github.com/chatwoot/chatwoot/pull/15425

## How to test

1. Select an assistant with recorded conversation outcomes.
2. Request the overview, resolution flow, and resolution trend endpoints
for different reporting ranges.
3. Confirm the overview summary returns no more than three distinct
points backed by rounded, softened report values rather than exact
figures.
4. Confirm low-volume reports qualify rate-based conclusions and reports
without meaningful activity return no points.
5. Request the overview summary again with the same assistant, range,
timezone, and locale, and confirm the successful summary is reused from
the one-hour cache.
6. Confirm raw statistics remain uncached and continue reflecting the
selected reporting range and timezone.

## Chatwoot CLI

View the 30-day statistics for assistant ID `1` using a UTC+5:30 viewer
timezone:

```bash
chatwoot api "/captain/assistants/1/stats/overview?range=30&timezone_offset=5.5" -o json
chatwoot api "/captain/assistants/1/stats/resolution_flow?range=30&timezone_offset=5.5" -o json
chatwoot api "/captain/assistants/1/stats/resolution_trend?range=30&timezone_offset=5.5" -o json
chatwoot api "/captain/assistants/1/stats/overview_summary?range=30&timezone_offset=5.5" -o json
```

## What changed

- Added a dedicated assistant stats controller for overview,
resolution-flow, and resolution-trend data.
- Added a server-generated overview summary based on all three reporting
datasets.
- Added a structured LLM response schema and a focused prompt for up to
three standout insights.
- Rounded and softened metric values in generated insights so cached
summaries do not present stale figures as exact.
- Cached successful LLM summaries for one hour while leaving raw
statistics uncached and avoiding caching generation failures.
- Added a frontend API client for the new endpoints.
- Preserved the existing assistant metrics and summary endpoints for
compatibility.
2026-08-18 13:59:13 +05:30
Muhsin Keloth
c8492c128d feat(campaigns): add whatsapp campaign analytics UI (#15369)
WhatsApp campaigns gain a dedicated analytics view for completed and
processing campaigns. Teams can monitor live delivery progress, review
campaign-level delivery metrics, and inspect recipient-level statuses
and failure reasons.

This PR contains only the frontend experience and uses the analytics
APIs and campaign execution fields introduced in #15276.

Related:
https://linear.app/chatwoot/issue/CW-4627/ee-basic-analytics-for-whatsapp-campaigns

### What changed

- Shows audience, sent, delivered, read, failed, and skipped metrics.
- Adds a delivery breakdown and recipient-level delivery table with
status filters and pagination.
- Exposes analytics while a campaign is processing and identifies live
data with an in-progress banner.
- Shows a waiting state when a processing campaign has not produced
delivery records yet.
- Shows an unavailable state for completed campaigns that do not have
stored recipient data.
- Prevents invalid analytics requests when navigating back to the
campaign list.

### Things to know

- Historical campaigns sent before recipient tracking was introduced may
not have analytics data.
- This PR does not change campaign sending, recipient tracking, or
webhook processing.
- Analytics visibility currently follows the Enterprise installation
check; subscription-plan entitlement is not included in this PR.

### How to test

1. Open Campaigns → WhatsApp.
2. Open a processing WhatsApp campaign and confirm the analytics action
is available.
3. Confirm the processing banner and live metrics are shown.
4. Open a completed campaign with recipient data and verify its metrics,
delivery breakdown, status filters, and pagination.
5. Open a completed historical campaign without recipient data and
confirm the unavailable state is shown instead of zero-valued metrics.
6. Filter the recipient table and confirm contact, message, status,
failure reason, and error code render correctly.
7. Return to the campaign list from the analytics breadcrumb and confirm
the page loads without an error.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-18 11:09:57 +04:00
Shivam Mishra
89a933f763 refactor(reports): migrate charts to @chatwoot/viz (#15458)
This PR replaces the Chart.js-based reporting charts with our internal
`@chatwoot/viz` library. The primary motivation is to support our native
Sankey visualization while maintaining a reusable charting package that
can be shared across Chatwoot projects.

## Preview

### Dashboard

#### Conversation Stats 

<img width="2114" height="1696" alt="CleanShot 2026-08-13 at 15 48
08@2x"
src="https://github.com/user-attachments/assets/88ada023-818a-4f2a-b574-1361ff3a55c1"
/>

<img width="2052" height="1598" alt="CleanShot 2026-08-17 at 17 18
17@2x"
src="https://github.com/user-attachments/assets/e88480fe-4663-4d24-9dae-d23e6ed61ccc"
/>


#### CSAT

<img width="2042" height="632" alt="CleanShot 2026-08-17 at 17 09 14@2x"
src="https://github.com/user-attachments/assets/610b2fc6-f67e-40b4-8607-a3ae690150fe"
/>

<img width="2064" height="640" alt="CleanShot 2026-08-17 at 17 18 00@2x"
src="https://github.com/user-attachments/assets/e5d18ec2-4215-4b8f-8e85-0d5a0f6fa999"
/>


#### Heatmap

<img width="2106" height="796" alt="CleanShot 2026-08-17 at 17 17 50@2x"
src="https://github.com/user-attachments/assets/f0943665-843e-40ae-8a93-b1c4acaafcf5"
/>

<img width="2106" height="796" alt="CleanShot 2026-08-17 at 17 17 40@2x"
src="https://github.com/user-attachments/assets/2b3824da-a70c-46e3-a2fd-1339173eadbf"
/>



## What changed

- Migrated report and Super Admin bar charts from Chart.js to
`@chatwoot/viz`.
- Migrated reporting heatmaps to the shared visualization library.
- Removed `chart.js` and `vue-chartjs`.
- Preserved report drill-down interactions, responsive behavior,
accessibility, and theme support.
- Improved duration axes with human-friendly tick intervals.
- Added contextual tooltip descriptions for first response, resolution,
and waiting-time metrics.

## How to test

1. Open Reports and verify conversation, message, first-response,
resolution, and waiting-time charts.
2. Check different date ranges and grouping options.
3. Hover over duration bars and verify the formatted value and
supporting conversation or reply count.
4. Click report bars and confirm drill-down navigation still works.
5. Verify conversation and resolution heatmaps, including their
tooltips.
6. Open the Super Admin dashboard and verify its conversation chart.
7. Check the charts at narrow and wide widths and in both light and dark
themes.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-08-17 18:57:32 +05:30
Muhsin Keloth
c58d7a6d60 revert(twilio): restore media downloads after redirect regression (#15502)
Reverts chatwoot/chatwoot#15499
2026-08-17 16:31:05 +04:00
Muhsin Keloth
4eb3c776f1 fix(twilio): retry transient whatsapp media downloads (#15499)
Twilio can briefly return a 404 for valid inbound WhatsApp media while
the attachment is still propagating. Chatwoot previously followed the
initial authenticated request with an unauthenticated retry, so the
attachment could still be skipped even after it became available.

This change keeps retries authenticated for trusted Twilio media URLs,
waits for bounded intervals, and preserves the inbound message when
media remains unavailable. Other media URLs retain the existing
anonymous download behavior.

Fixes
https://linear.app/chatwoot/issue/PLA-206/retry-transient-twilio-whatsapp-media-404s-before-skipping-attachments

### What changed

- Validate the HTTPS Twilio API host and exact account, message, and
media path before sending channel credentials.
- Retry Twilio media 404 responses after 1 and 3 seconds using
authentication on every attempt.
- Stop retrying on other download failures and log retry, success, and
skipped outcomes with message and inbox context.
- Keep the inbound text message when all attachment attempts are
exhausted.

### How to reproduce

1. Receive an inbound Twilio WhatsApp message with media.
2. Make the Twilio media endpoint return 404 for the first authenticated
request and succeed shortly afterward.
3. Before this change, the follow-up request is unauthenticated and the
attachment is skipped.
4. With this change, the authenticated retry downloads and attaches the
media.

### How to test

1. Open the affected Twilio WhatsApp conversation.
2. Trigger an inbound message whose valid Twilio media URL returns 404
once, then succeeds.
3. Confirm the message appears with its attachment after the retry.
4. Repeat with persistent 404 responses and confirm the text message
remains without an attachment.

The focused RSpec suite passes with 46 examples, and the changed Ruby
files pass RuboCop.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-17 15:44:36 +04:00
Sony Mathew
8d263a06a0 fix: restore Rails 7.2 schema dumps (#15500)
# Pull Request Template

## Description

Restores Rails migrations and local worktree database preparation after
the Rails 7.2 upgrade. The lockfile now resolves a Rails 7.2-compatible
`hairtrigger` release and a `ruby_parser` release that understands Ruby
3.4, allowing the post-migration schema dump to complete while
preserving Chatwoot's database triggers.

Closes
[CW-7968](https://linear.app/chatwoot/issue/CW-7968/fix-dbschemadump-after-rails-72-upgrade)

## Type of change

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

## How Has This Been Tested?

Reproduced the failure using a disposable PostgreSQL database loaded
from the schema immediately before the campaign-recipient migration. The
repaired worktree setup applied the migration, completed the schema
dump, and retained the account, conversation, and campaign trigger
definitions. Focused trigger behavior specs, dependency audit, bundle
verification, and lint checks also passed.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-17 16:50:26 +05:30
Vishnu Narayanan
d7e49c3ee9 fix: prevent duplicate results in paginated search (#15446)
## Description

**The bug:** When searching in the dashboard, the same message could
show up twice in the results list.

**Why it happens:** Search results load 15 at a time. When you click
"Load more", the next 15 are fetched by asking the server to skip the
first 15 and return the rest. The catch: if new messages were created
between the two fetches, everything shifts down. A message that was in
position 14 during the first fetch may now be in position 16, so the
second fetch returns it again. The UI appended everything it received
without checking, so that message appeared twice on screen.

A smaller variant of the same problem: results are sorted by
`created_at`, and two messages can share the same timestamp. The
database and OpenSearch are free to return such tied rows in any order,
so the order can differ between two fetches and a row can slip from one
page into the next.

**The fix:**

- The store now skips records that are already in the list when
appending a page, so a re-served record can never render twice
- Whether "Load more" is shown is now based on the size of the raw API
response instead of the on-screen count, so pagination keeps working
when overlapping records get dropped
- Ties are now broken by message `id` on every search path (LIKE, GIN,
OpenSearch), making the sort order stable between fetches
- For OpenSearch this needs `id` in the index. No reindex is required:
new documents pick the field up automatically, and `unmapped_type` keeps
existing indexes working unchanged

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

## Type of change

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

## How Has This Been Tested?

### Reproduction

Verified on `develop` using the SQL search path.

1. Seeded 100 conversations with normal non-matching messages.
2. Added 20 matching messages containing `assignment handoff message`,
spread across separate conversations.
3. Opened dashboard advanced search and searched for `assignment
handoff`.
4. The Messages tab loaded page 1 with 15 results, starting with
conversation `#569`.
5. While page 1 was still open, inserted 15 newer matching messages in
separate conversations.
6. Clicked **Load more**.

### Result on `develop`

The second page appended the same page-1 rows again. In the screenshot,
page 1 starts with `#569`, and after Load more the list reaches `#499`
then appends `#569` again.

This confirms the offset-pagination bug: new matching rows inserted
between page 1 and page 2 shift the offset, so page 2 re-fetches rows
that were already rendered.

<img width="1120" height="1455" alt="image"
src="https://github.com/user-attachments/assets/987652da-2eda-4c63-a3ae-48becee87e43"
/>


### Fix verification

Verified the same repro on `fix/cw-7856-search-pagination-tiebreaker`.

1. Seeded 100 conversations with non-matching messages.
2. Added 20 matching messages spread across separate conversations.
3. Opened the Messages search page and loaded page 1.
4. While page 1 was open, inserted 15 newer matching messages.
5. Clicked **Load more**.

### Result on fix branch

No duplicate rows were rendered.

The raw page-2 API window still overlapped with page 1, which is
expected with offset pagination after newer rows are inserted. The
frontend store dropped the already-rendered records during append, so
the visible list stayed unique.

`Load more` also remained available after the overlapped page, and the
next page returned the remaining older results.
2026-08-17 16:20:22 +05:30
Sivin Varghese
3d3838a93c fix: broken previews and empty rows in contact history (#15478) 2026-08-17 14:53:01 +05:30
Muhsin Keloth
8ee2c7f432 feat(campaigns): track whatsapp campaign delivery analytics (#15276)
WhatsApp campaigns now store delivery data for every intended recipient
so delivery performance can be inspected after a campaign runs. The
backend records the actual campaign start and completion times, captures
sent, delivered, read, failed, and skipped outcomes, and exposes metrics
and recipient-level results for the analytics UI.

The analytics APIs and recipient tracking are available for one-off
WhatsApp campaigns on Enterprise installations when the
`whatsapp_campaign` account feature is enabled. The shared campaign
lifecycle records and exposes actual start and completion timestamps for
all one-off campaigns.

Related:
https://linear.app/chatwoot/issue/CW-4627/ee-basic-analytics-for-whatsapp-campaigns

### What changed

- Create a campaign recipient record for every contact selected by the
campaign audience.
- Store rendered message content, provider message ID, delivery
timestamps, and provider failure details.
- Update recipient status from WhatsApp delivered, read, and failed
webhooks without allowing status downgrades.
- Store `started_at` when campaign processing begins and `completed_at`
when it finishes.
- Add metrics and paginated, status-filtered recipient analytics
endpoints.

### Things to know

- This PR contains the backend implementation only. The analytics UI is
in #15369 and is based on this branch.
- Recipient analytics currently cover one-off WhatsApp campaigns only;
`started_at` and `completed_at` are shared by all one-off campaign
types.
- Access is controlled by the existing `whatsapp_campaign` account
feature. Plan entitlement configuration is outside this PR.
- The migration creates `campaign_recipients` and adds `started_at` and
`completed_at` to campaigns.
- Retry, automatic reattempt, and campaign-specific reply attribution
are not included.

### How to test

1. Enable `whatsapp_campaign` for an Enterprise account.
2. Create a one-off WhatsApp campaign with a label-based audience that
includes valid contacts and at least one contact without a phone number.
3. Trigger the campaign and confirm it records `started_at`,
`completed_at`, and one campaign recipient per audience contact.
4. Confirm successful sends store the provider message ID and skipped or
failed recipients store the corresponding reason.
5. Process delivered, read, and failed WhatsApp status webhooks and
confirm recipient states and timestamps update.
6. Request the campaign metrics endpoint and confirm the audience and
delivery counts.
7. Request the contacts endpoint with status and page parameters and
confirm filtering and pagination.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-17 12:32:16 +04:00
Pedro Rodriguez Ortiz
c6c34ae4ea feat(whatsapp): show ad referral card for CTWA leads (#14466)
## Summary

Adds an agent-facing UI for the Click-to-WhatsApp (CTWA) referral data
that #12560 / #14121 captures on
`conversation.additional_attributes.referral`.

When a contact starts a WhatsApp conversation by clicking on a
Facebook/Instagram CTWA ad, Meta sends a `referral` object with the ad's
metadata (source URL, headline, body, media, the welcome message that
was pre-filled in the customer's WhatsApp). #14121 stores this on the
conversation. This PR surfaces it in the dashboard so the agent can see
exactly which ad brought the lead — replicating the "lead source"
pattern that Kommo and similar CRMs offer for WhatsApp.

## Type of change

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

## Closes / depends on

- Depends on #14121 (backend referral capture).
- Addresses the UX side of #12560.

## What this adds

- **`components-next/Conversation/ReferralBubble.vue`** — a standalone,
additive card rendered at the top of the conversation. It shows:
  - the ad's poster/image (or video player for video ads),
  - the headline and body,
  - a **View ad** link that opens `source_url` in a new tab,
- the welcome message that was pre-filled in the customer's WhatsApp,
and
- a discreet "Customer replied below" hint so the agent reads the
conversation top-down with full context.
- Visual treatment: dashed amber border, megaphone icon, "Ad" / "Post"
label — intentionally distinct from message bubbles so it can't be
mistaken for something the customer sent.
- **`components-next/Conversation/ReferralBubble.story.vue`** — Histoire
story with four variants (image ad, video ad, text-only post, minimal
payload).
- Integration into `MessagesView.vue` via the existing `beforeAll` slot
on `MessageList` — no changes to `MessageList` or `Message` components.
- i18n keys in `en/conversation.json` only (other locales via Crowdin).

## How it behaves when referral data is absent

The bubble renders strictly when
`currentChat.additional_attributes.referral` is present. For all
non-CTWA conversations the component is never instantiated, so this is
inert for everything else. No feature flag is necessary.

## How Has This Been Tested?

- Lint: `pnpm eslint
app/javascript/dashboard/components-next/Conversation/ReferralBubble.vue
app/javascript/dashboard/components-next/Conversation/ReferralBubble.story.vue`
— clean.
- Manual:
  1. Inject a sample referral on a WhatsApp conversation:
     ```ruby
     conv = Conversation.find_by(display_id: <id>, account_id: <id>)
     conv.update!(additional_attributes: {
       referral: {
         source_url: 'https://fb.me/sample-ad',
         source_type: 'ad',
         headline: 'Sample Ad',
         body: 'Sample body text.',
         media_type: 'image',
         image_url: 'https://picsum.photos/seed/ad1/800/420.jpg',
         welcome_message: { text: 'Hi, more info please' }
       }
     })
     ```
2. Open the conversation in the dashboard — the bubble renders above the
messages.
  3. Click **View ad** — opens `source_url` in a new tab.
4. Switch the payload to `media_type: 'video'` with a `video_url` — the
bubble renders a poster with a play overlay; clicking it opens the ad.
5. Drop `welcome_message` — the bubble omits the pre-filled hint
cleanly.
  6. Drop `image_url` and `video_url` — the bubble renders text-only.
  7. Open any non-CTWA conversation — no bubble is rendered.

## Screenshots

<img width="1372" height="1328" alt="CleanShot 2026-08-16 at 14 46
29@2x"
src="https://github.com/user-attachments/assets/d65aeb9f-d390-465f-9728-c03491949e7b"
/>

## 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] Any dependent changes (#14121) have been merged or are mentioned
above

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-08-17 11:29:13 +04:00
Tanmay Deep Sharma
e52a731bf9 fix(voice): sync Twilio call accept/end state across tabs (#15413)
## Summary

When an agent has multiple tabs/windows open and picks up (or ends) a
Twilio voice call in one of them, the other tabs never find out — they
keep ringing and showing the call as still incoming, even though it's
already been answered or has ended elsewhere.

Chatwoot already broadcasts this kind of accept/end state for WhatsApp
calls so every other tab clears its ringing UI, but the equivalent never
existed for Twilio — neither the backend event nor the frontend
listener.

## How to reproduce

1. Log into the same agent account in two browser tabs.
2. Receive an inbound Twilio call (rings in both tabs).
3. Accept the call in one tab.
4. The other tab keeps ringing and shows the call as still incoming.

Same issue in reverse when discarding/ending a call from one tab — the
others keep showing it as active/ringing.

## What changed

- `Api::V1::Accounts::ConferenceController#create`/`#destroy` now
broadcast `voice_call.accepted`/`voice_call.ended` account-wide for
Twilio calls, mirroring the existing `Whatsapp::CallService` broadcast
shape.
- `onVoiceCallAccepted`/`onVoiceCallEnded` in `actionCable.js` no longer
ignore non-WhatsApp providers — they now route through the right
local-ownership check per provider before deciding whether to tear the
call down in the current tab.
- Added a Twilio-side `markLocalCall`/`isLocalCall`/`clearLocalCall` (in
`dashboard/helper/voice.js`), the equivalent of WhatsApp's
`activeCallId`, set synchronously before the join API call so the
account-wide broadcast can't race ahead of the joining tab's own state
and tear down its live call.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-08-17 09:44:12 +05:30
Sivin Varghese
9a73c1473f feat: add icons to macro and automation dropdowns (#15447) 2026-08-14 08:45:58 +05:30
Shivam Mishra
59942e6890 fix: inbox contact sidebar overflow (#15470)
Keeps the inbox conversation and contact sidebar within the available
viewport at desktop breakpoints.

## Preview



https://github.com/user-attachments/assets/aba1e25e-8741-4544-8659-a6024ad1e887



## How to reproduce

1. Open an inbox notification with the contact sidebar visible.
2. Resize the viewport around the 1536px breakpoint or expand the main
navigation.
3. Confirm the inbox list, conversation, and contact sidebar remain
visible.
2026-08-13 19:50:03 +05:30
Vishnu Narayanan
fefa0a6966 perf: add composite index for account-scoped conversation created_at sort (#15360)
## Description

Adds a composite index `(account_id, status, created_at)` on
`conversations`.

Sorting an account's conversation list by `created_at` ("newest first")
across all inboxes has no supporting index today, so Postgres falls back
to a backward scan of the global `index_conversations_on_created_at`
filtered by `account_id`. On large databases the planner estimates a
very deep scan and the query can exceed the statement timeout. The
composite index lets the ordered limit be served directly from the
`account_id`/`status` prefix. The default `last_activity_at` sort and
inbox-filtered views are unaffected (already indexed).

The index is created with `algorithm: :concurrently` (no table lock). On
very large installations, create it out-of-band concurrently before
deploying so the migration doesn't run a long build.

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

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2026-08-13 18:34:12 +05:30
Shivam Mishra
40f5381da6 feat: add Captain outcome reporting builders (#15425)
This PR adds outcome-based reporting builders for the redesigned Captain
overview, resolution flow, and resolution trend. These builders are not
wired to controllers or the frontend yet, so the existing Captain
metrics remain unchanged.

## AssistantOverviewStatsBuilder

Builds current and previous reporting-window metrics, including the
comparison trend, for the overview and CSAT cards.

| Statistic | Description |
| --- | --- |
| Conversations handled | Counts outcome episodes in which Captain
replied or performed a non-usage-limit handoff. |
| Auto-resolution rate | Shows autonomous resolutions as a percentage of
conversations handled. |
| Autonomous resolutions | Counts episodes resolved by Captain without a
handoff or an earlier human reply. |
| Handoff rate | Shows involved handoffs as a percentage of
conversations handled. |
| Handoff count | Counts involved handoffs while excluding demand
blocked by usage limits. |
| Hours saved | Estimates displaced agent effort from Captain's public
replies at two minutes per reply. |
| Reopen rate | Shows the share of autonomous resolutions followed by
another episode. |
| Conversation depth | Shows the average number of public Captain
replies per replied-to conversation. |
| Durable resolution rate | Shows autonomous resolutions that remained
closed for at least seven days among resolutions old enough to assess. |
| Autonomous CSAT score | Averages CSAT ratings from conversations
resolved autonomously by Captain. |
| Assisted CSAT score | Averages CSAT ratings from resolved
conversations where Captain participated alongside a human. |
| Human-only CSAT score | Averages account CSAT from conversations where
Captain never participated. |
| Median resolution time | Reports the median elapsed seconds from
demand start to resolution for handled episodes. |

## AssistantResolutionFlowBuilder

Builds the current-window Sankey data and a matching handoff-reason
distribution from the same outcome cohort.

| Statistic | Description |
| --- | --- |
| Conversations handled | Provides the Sankey entry count for episodes
where Captain participated. |
| Resolved by Captain | Counts handled episodes resolved autonomously by
Captain. |
| Handed off | Counts handled episodes transferred to a human for a
non-usage-limit reason. |
| Closed with team | Counts handled episodes outside the
autonomous-resolution and handoff branches. |
| Reopened within seven days | Counts Captain resolutions followed by a
new episode before the seven-day durability boundary. |
| Stayed closed | Counts Captain resolutions with no reopen inside seven
days. |
| Handoff reason nodes | Shows the two largest handoff categories and
combines the remainder as other reasons. |
| Handoff distribution | Returns every involved handoff category with
its count and percentage, including unclassified handoffs. |

## AssistantResolutionTrendStatsBuilder

Builds a zero-filled, timezone-aware resolution series in one outcome
query, using daily buckets for windows of 15 days or less and weekly
buckets for longer windows.

| Statistic | Description |
| --- | --- |
| Granularity | Identifies whether the response contains daily or weekly
buckets. |
| Bucket range | Returns the start and end date represented by each
bucket. |
| Conversations handled | Counts Captain-involved outcome episodes whose
demand started in each bucket. |
| Resolved by Captain | Counts autonomously resolved outcome episodes
whose demand started in each bucket. |
2026-08-13 18:29:22 +05:30
Vishnu Narayanan
a929955cc3 fix: escape formula characters in contact CSV export (#15334)
## Description

Contact export builds its CSV with the standard library `CSV`, which
writes user-supplied values (name, email, phone, custom attributes)
verbatim. When a cell begins with a formula character (`=`, `+`, `-`,
`@`, and tab/CR), spreadsheet applications interpret it as a formula on
open. This switches the export to `CSVSafe` (the `csv-safe` gem already
used by the v2 report exports), which prefixes such fields so they are
treated as text.

No new dependency, no behavioural change beyond neutralising
formula-leading cells. Note: phone numbers stored with a leading `+` are
now prefixed with a single quote in the exported file, consistent with
how the report exports already behave.

Ref https://linear.app/chatwoot/issue/CW-7473

## Type of change

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

## How Has This Been Tested?

`bundle exec rspec spec/jobs/account/contacts_export_job_spec.rb` —
added a case asserting a formula-leading contact value is neutralised on
export; existing cases updated for the phone-number prefix.

## 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
2026-08-13 18:09:38 +05:30
Aakash Bakhle
8864f80ab7 feat(captain): show document conversation usage (#15140)
## Summary

Adds conversation usage to Captain documents and user created FAQs. Only
knowledge used in a Captain answer sent to the contact is counted.
Lookups that end in a handoff are excluded.

Administrators can see how many distinct conversations used a knowledge
record. Document usage appears in the Usage tab inside document details.
User created FAQ usage appears on each FAQ card and opens in a
conversation panel.

Deleted conversations are excluded from counts, sorting, and
conversation lists.

Usage shown in side panel
<img width="2342" height="1502" alt="CleanShot 2026-08-12 at 18 29
02@2x"
src="https://github.com/user-attachments/assets/67301051-6df5-4eb6-9b8d-e1fc75bb02f2"
/>

Sorting options
<img width="580" height="241" alt="image"
src="https://github.com/user-attachments/assets/078566b7-481b-491b-b484-c2fef481f1b1"
/>


## Access

Conversation usage is available only to administrators. Agents can still
view documents and FAQs, but they cannot see usage counts, the Usage
tab, the "Most used" sort, or conversation usage details.

The API applies the same rule. An agent request for `sort=most_used` is
rejected.

## Performance and pagination

Document and FAQ lists return 25 records per page. Usage counts are
calculated only for the records in each page.

Conversation usage panels load 25 conversations at a time and show a
"Load more" action when more conversations are available. The generated
FAQs tab also keeps its existing 25 item pagination.

The count queries use the JSON indexes on `document_ids` and
`used_faq_ids`. The "Most used" sort aggregates one assistant's sessions
once before it sorts and returns the requested document page.

## How to test

1. Sign in as an administrator and open the Captain documents page for
an assistant with tracked document usage.
2. Open a document and confirm that the Usage tab shows the distinct
conversation count and the matching conversations.
3. Select "Most used" and confirm that documents are ordered by distinct
conversation usage.
4. Open the user created FAQs page and confirm that FAQ cards show their
usage count and open the matching conversations.
5. Confirm that usage panels show 25 conversations first and can load
the next page.
6. Trigger a knowledge lookup that ends in a handoff and confirm that it
does not increase document or FAQ usage.
7. Sign in as an agent and confirm that usage counts, usage details, and
the "Most used" sort are not available.

## Closes

[CW-7498](https://linear.app/chatwoot/issue/CW-7498/fe)
2026-08-13 17:39:49 +05:30
Sony Mathew
b3a1dbca81 fix: oversized message cursors (#15400)
Message pagination now constrains client-provided cursors to the
PostgreSQL integer range used by `messages.id`, preventing oversized
values from raising database errors while preserving before/after
pagination semantics.

## Closes

-
[CW-7922](https://linear.app/chatwoot/issue/CW-7922/harden-backend-paths-causing-production-sentry-errors)
- [Sentry 7663397140](https://chatwoot-p3.sentry.io/issues/7663397140/)
- [Sentry 7663397546](https://chatwoot-p3.sentry.io/issues/7663397546/)
- [Sentry 7663464772](https://chatwoot-p3.sentry.io/issues/7663464772/)

## How to reproduce

Request conversation messages with an extremely large `before` or
`after` cursor, such as `4611686018427387903`. PostgreSQL previously
rejected the value as outside the range for the integer `messages.id`
column.

## What changed

- Normalize message cursors before they reach the query.
- Clamp them to the valid signed 32-bit integer ID range.
- Cover oversized `before` and `after` cursors with finder specs.
2026-08-13 17:34:05 +05:30
Sony Mathew
dd203438d3 fix: reject malformed filter payloads (#15398)
Contact and conversation filters now reject malformed numeric/date
custom-attribute values and dangling query operators during validation,
returning the existing invalid-filter response instead of failing later
while building SQL.

## Closes

-
[CW-7922](https://linear.app/chatwoot/issue/CW-7922/harden-backend-paths-causing-production-sentry-errors)
- [Sentry 7663920842](https://chatwoot-p3.sentry.io/issues/7663920842/)
- [Sentry 7663378104](https://chatwoot-p3.sentry.io/issues/7663378104/)

## How to reproduce

Submit a custom numeric attribute filter with an empty value, or end a
filter payload with a nonblank `query_operator`. These inputs previously
reached coercion/query construction and raised production exceptions.

## What changed

- Validate numeric and date custom-attribute values before query
construction.
- Preserve presence operators, which do not require a comparison value.
- Reject a query operator on the final filter condition.
- Add contact and conversation service regression coverage.
2026-08-13 17:04:11 +05:30
Sony Mathew
8a778f4f5b fix: handle empty round robin queues (#15399)
Round-robin queue resets now leave the Redis queue empty when an inbox
has no members, avoiding an invalid sorted-set write while keeping
normal queue population unchanged.

## Closes

-
[CW-7922](https://linear.app/chatwoot/issue/CW-7922/harden-backend-paths-causing-production-sentry-errors)
- [Sentry 7663380084](https://chatwoot-p3.sentry.io/issues/7663380084/)

## How to reproduce

Run round-robin assignment for an inbox with no inbox members. Resetting
the queue previously attempted a Redis sorted-set write without any
member/score pairs.

## What changed

- Clear the existing queue as before.
- Skip queue population when there are no eligible user IDs.
- Add service coverage for an inbox with no members.
2026-08-13 17:03:36 +05:30
Vishnu Narayanan
ba1973ea40 fix: escape formula characters in CSAT CSV export (#15335)
## Description

The CSAT survey response CSV export builds rows with the standard
library `CSV`, writing values verbatim, including the feedback message,
which is free text submitted by end users. When a cell begins with a
formula character (`=`, `+`, `-`, `@`, and tab/CR), spreadsheet
applications interpret it as a formula on open. This switches the three
`CSV.generate_line` calls in the export template to
`CSVSafe.generate_line` (the `csv-safe` gem already used by the v2
report exports), which prefixes such fields so they are treated as text.

No new dependency, no behavioural change beyond neutralising
formula-leading cells.

Ref https://linear.app/chatwoot/issue/CW-7473

## Type of change

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

## How Has This Been Tested?

`bundle exec rspec
spec/controllers/api/v1/accounts/csat_survey_responses_controller_spec.rb`
— added a case asserting a formula-leading feedback value is neutralised
in the downloaded CSV.

## 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
2026-08-13 16:23:05 +05:30
Aakash Bakhle
13de83d1dc refactor(captain): route conversation completion by feature (#15317)
Conversation completion evaluations now use a dedicated internal LLM
feature with GPT 4.1 as the default. The internal route keeps the
completion model separate from the installation wide Captain model
override and from the assistant route, which can use GPT 5.2 for Captain
V2 accounts. Evaluations continue to use the installation API key and do
not consume Captain response credits.

## What changed

Added an internal `conversation_completion` feature to the LLM model
config and excluded internal features from account preferences, the
Captain settings API, and Super Admin model overrides.

Updated `Captain::ConversationCompletionService` to resolve its model
through `Llm::FeatureRouter`.

Added focused service and request coverage for model routing and
settings visibility.
2026-08-13 13:54:35 +05:30
Aakash Bakhle
eff7ce2898 fix: consolidate customer message bursts before lookup (#15263)
Captain now treats consecutive customer messages as one request before
it asks a question, searches the knowledge base, or suggests a solution.
It uses the full message burst to identify the customer's goal and
current state. It checks conflicting details before relying on them.

## How to test

1. Send several customer messages in a row that describe one issue. Add
a short correction in a later message.
2. Confirm Captain uses all the messages before it asks a question,
searches the knowledge base, or suggests a solution.
3. Send another message with a detail that conflicts with information
Captain already checked. Confirm Captain checks the detail again before
relying on it.
2026-08-13 13:23:43 +05:30
Aakash Bakhle
b24a970795 fix: allow multi-digit values in duration inputs (#15429)
## Description

The shared duration input enforced its minimum after every keypress.
When the minimum was 10, typing `45` changed the first digit to `10`, so
the second digit produced `105`.

This change lets people finish typing before the input applies its
minimum and maximum. The input now normalizes the value when it loses
focus or when the person presses Enter.

## Type of change

- [x] Bug fix

## How has this been tested?

- Added a component test that types `4`, then `5`, and confirms that the
value stays `45`.
- Confirmed that values below 10 and above 100 are normalized when the
input loses focus.
- Ran the focused Vitest file and ESLint.
- Ran a local deterministic Playwright test through the delayed
automation form. The test typed `45`, saved the rule, confirmed
`execution_delay: 45` in the API response, confirmed `Runs after 45m` in
the list, and removed the test rule.

## 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 fix works.
- [x] The focused unit and browser tests pass locally.
2026-08-13 12:52:13 +05:30
Sivin Varghese
45878923b6 feat: run macros from the command bar (#15422) 2026-08-13 12:47:23 +05:30
Sivin Varghese
416ac8660f fix: fit the reply editor to Copilot suggestions (#15428)
# Pull Request Template

## Description

This PR fixes the reply editor staying at its default height when
Copilot generates a suggestion, causing longer suggestions to be clipped
after a line or two.

The editor now automatically grows to fit the suggestion, up to a
maximum of 350px, and returns to the height it had before the suggestion
when it is accepted or discarded. Manual resizing continues to work as
expected and always takes priority.

### How to reproduce

1. Open a conversation and generate a Copilot suggestion (✨ → Summarize
the conversation).
2. The reply editor stays at its default height and the suggestion gets
clipped after a line or two.
3. Resize the reply editor, generate another suggestion, and discard it.
The editor no longer returns to the height you set.

### What changed

* `CopilotEditor` measures the rendered suggestion and requests enough
space to display it, capped at 350px. The requested height is released
when the suggestion is gone.
* `ResizableEditorWrapper` handles this requested height separately from
the manually dragged height, so the suggestion can grow the editor
without overriding the user's preferred height.
* The loading and suggestion states now cross-fade in place while the
editor resizes, instead of briefly switching through an empty card.


### Type of change

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

## How Has This Been Tested?

### Screencast


https://github.com/user-attachments/assets/d9a5dab9-6206-4333-92d1-d3f710f9022e




## 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
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-13 12:39:56 +05:30
Sivin Varghese
13dfe1a6c6 feat: run macros from the reply editor with # (#15416) 2026-08-13 12:23:35 +05:30
Manoj
1d905fb019 fix: anchor Label title validation with \z instead of \Z (#15443)
## Description

This isn't tied to an open issue — I found it by extrapolating from the
bug class fixed in #15415 ("fix: anchor contact phone number
validation"), which fixed a `Contact#phone_number` format validation
that was missing a leading `\A` anchor. That made me audit every
hand-written regex-based validation in the codebase for the same class
of anchoring mistake (`format: { with: ... }` validators, plus
`match?`/`=~` calls used for validation-style checks, across `app/`,
`enterprise/`, and `lib/`).

Everything else was already correctly anchored. One real instance of the
*sibling* mistake remains:

`RegexHelper::UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE` (used only by
`Label#title`'s format validation) is
`/\A[\p{L}\p{N}]+[\p{L}\p{N}_-]+\Z/` — note `\Z` (capital), not `\z`.
Unlike `\z`, `\Z` also matches just before a single trailing `"\n"` at
the end of the string. The surrounding comment documents the intended
character set (unicode letters/numbers/underscore/hyphen, not starting
with `_`/`-`) and says nothing about tolerating a trailing newline, so
this reads as an unintentional choice of anchor rather than a deliberate
one.

Concretely: `Label.new(title: "hello_world\n").valid?` returns `true` on
current `develop` and persists a title with a literal trailing newline,
because `\Z` lets the `\n` slip through. `Label` only lowercases the
title before validating (no `strip`), so nothing else catches this.

This is a narrower/lower-severity variant of the #15415 bug (it only
ever admits one specific trailing character, not an arbitrary
prefix/suffix), but it's the same underlying mistake, independently
verified against current source, not just pattern-matched from the diff.

## What changed

- `lib/regex_helper.rb`: `UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE`
now ends in `\z` instead of `\Z`, with a comment explaining why.

## Type of change

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

## How Has This Been Tested?

Ran against a real local Rails env (Ruby 3.4.4, PostgreSQL 16, Redis):

`bundle exec rspec spec/models/label_spec.rb`

Added a regression test asserting `Label.new(title: "hello_world\n")` is
invalid. Confirmed it fails against the pre-fix `\Z` regex and passes
after switching to `\z`. All existing `label_spec.rb` examples
(including the existing "foreign characters", "special characters", and
"uppercase" title-validation cases) continue to pass unchanged.

## 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
- [x] New and existing unit tests pass locally with my changes

🤖 This fix was authored by an AI coding agent (Claude) working on behalf
of Mithtech, an ERPNext/Frappe/Medusa.js implementation studio, as part
of a deliberate effort to build a track record of verified upstream
open-source contributions. Flagging this transparently per common
courtesy — happy to answer any questions about the change, including how
it was found (auditing for the same regex-anchor mistake class as
#15415).

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:05:06 +05:30
Sivin Varghese
2ed68cf207 feat: cache canned responses in the browser (#15401) 2026-08-13 11:54:32 +05:30
Sivin Varghese
590e17dfb7 chore: remove the legacy notifications page (#15426) 2026-08-13 11:49:44 +05:30
Muhsin Keloth
046c6c754f fix(messages): resend failed messages through provider (#15432)
Failed messages on external provider-delivery channels can appear sent
after an agent selects Retry even though the retry never reaches the
provider. This change clears the stale provider delivery ID before
retrying the existing message, allowing the channel service to create a
fresh provider attempt and store its new ID.

API and WebWidget messages are excluded because their `source_id` values
may be client-supplied correlation identifiers and their retry jobs do
not assign replacement provider IDs. Concurrent or stale retry requests
are also guarded so only the request that successfully claims a failed
message can enqueue delivery.

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

Related:
https://linear.app/chatwoot/issue/CW-6896/retrying-a-failed-whatsapp-message-silently-succeeds-locally-but-never

### Things to know

- Applies to external provider-delivery channels, including direct
WhatsApp and Twilio WhatsApp.
- Preserves `source_id` for `Channel::Api` and `Channel::WebWidget`.
- Reuses the existing Chatwoot message record while allowing the
provider to return a new message ID.
- Logs the cleared provider ID and Chatwoot message ID when a stale
provider ID is removed.

### How to reproduce

1. Send a message that the provider accepts and later marks as failed
through a status callback.
2. Select Retry on the failed message.
3. Observe that Chatwoot changes the message to sent but does not
contact the provider because the previous provider message ID remains
present.

### How to test

1. In an external provider inbox, retry a failed message that has an
existing provider message ID.
2. Confirm the old provider ID is cleared before delivery and the retry
reaches the provider.
3. Confirm the provider assigns a fresh message ID and subsequent status
callbacks update the retried message.
4. Retry a failed API or WebWidget message and confirm its existing
`source_id` is preserved.
5. Trigger concurrent retry requests for the same failed message and
confirm only one delivery job is queued.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-13 11:35:52 +05:30
Muhsin Keloth
10e9cb5b60 fix(whatsapp): load health status for regional locales (#15440)
The WhatsApp health status page fails to render for accounts using
underscore-based regional locales such as `pt_BR`. The browser rejects
that locale when formatting the last-onboarded timestamp, leaving the
page blank even though health data loaded successfully.

This change formats the timestamp with Chatwoot’s shared resolved
locale, which normalizes and validates the locale before passing it to
the browser. Other health status behavior remains unchanged.

### How to reproduce

1. Set the dashboard language to Portuguese (Brazil).
2. Open an inbox’s WhatsApp health status page when the response
includes `last_onboarded_time`.
3. Observe that the page remains blank with `RangeError: Invalid
language tag: pt_BR` in the browser console.

### How to test

1. Use an account with Portuguese (Brazil) selected.
2. Open the WhatsApp health status page for an inbox with onboarding
data.
3. Confirm the complete health status page loads and the last-onboarded
timestamp is formatted in the selected locale.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-13 10:26:06 +05:30
Vishnu Narayanan
3453e2975d chore: land super admin on dashboard after login (#15411)
## Description

After a successful super admin login, redirect to the super admin
dashboard (`super_admin_root_path`) instead of the users list. The
dashboard is a lighter, overview-first landing page, while the users
index does an exact count over the full users table on every load and
can be slow on large instances.

This only changes the post-login landing page. The users list is still
reachable from the navigation.

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

## Type of change

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

## How Has This Been Tested?

Added request specs for `SuperAdmin::Devise::SessionsController#create`:
- successful login redirects to `super_admin_root_path`
- invalid credentials redirect back to the login page

`bundle exec rspec
spec/controllers/super_admin/devise/sessions_controller_spec.rb` -> 3
examples, 0 failures.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] 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
2026-08-12 18:35:21 -07:00
Vijay Misal
adef9f2cd6 fix: Alt+E resolve shortcut also opens Chrome's built-in menu on Windows (#15418) 2026-08-12 21:54:34 +05:30
nikita42
42ca0901c2 fix(editor): handle parenthesized ordered list markers (#15362) 2026-08-12 21:49:03 +05:30
Khush Raghav Nanda
958fc37ba1 fix(conversation): refactor layout utilities to logical properties for RTL (#15341) 2026-08-12 21:35:17 +05:30
dependabot[bot]
d310e2c619 chore(deps-dev): bump postcss from 8.4.47 to 8.5.23 (#15380) 2026-08-12 19:09:56 +05:30
Corey
f9cdfe0071 fix: Use dark text on amber solid buttons so the disabled Add Note button stays visible (#15371) 2026-08-12 18:52:46 +05:30
Aakash Bakhle
a47eb375ad feat(captain): add advanced inactivity policy backend (4/5) (#15306)
Captain can now use each assistant's saved setting when a customer stops
replying. Captain can review the conversation and resolve or hand it
off, resolve it after the selected time without review, or leave it
pending until the customer replies.

The job checks the conversation again while holding a database lock
before it changes the status. A new customer reply or another worker
cannot cause an outdated resolve or handoff.

## Closes

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

## What changed

- Added assistant modes for review, always resolve, and wait for the
customer.
- Kept the account setting as the fallback for assistants that do not
have a saved mode.
- Skipped scheduling when resolution is disabled on the assistant or
through the older account setting.
- Rechecked the conversation status and activity time before each
resolve or handoff.
- Recorded events only after a status change succeeds.
- Kept out of office messages out of campaign conversations.

## How to test

1. Set an assistant to review conversations. Run the inactivity job with
complete and incomplete decisions. Confirm the first conversation is
resolved and the second is handed off.
2. Set the assistant to always resolve. Confirm an eligible pending
conversation is resolved after the selected time.
3. Set the assistant to wait for the customer. Confirm the scheduler
does not enqueue the inactivity job and the conversation remains
pending.
4. Add a customer reply while the review is running. Confirm the job
does not resolve or hand off the updated conversation.
5. Run two workers for the same conversation. Confirm only one status
change and one event are recorded.

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-12 18:33:48 +05:30
Aakash Bakhle
121b743f88 fix: scope Captain assistant responses to account (#15386)
## Description

Fixes account scoping for Captain assistant responses.

Create and update accepted an `assistant_id` from the request. The model
then set the response account from that assistant. The controller lookup
read the top level parameter, while the API sends the ID inside
`assistant_response`, and create did not use the lookup result.

The controller now resolves the nested assistant ID through
`Current.account`, removes `assistant_id` before assigning request
fields, and assigns the scoped assistant directly. The model now fills
the account only when it is blank and rejects a response when its
account and assistant do not match.

Linear issue:
[CW-7913](https://linear.app/chatwoot/issue/CW-7913/ghsa-phpm-m2mf-r8r9-captain-assistant-responses-writes-into-another)

## Type of change

- [x] Bug fix

## How has this been tested?

- Ran `bundle exec rspec
spec/enterprise/controllers/api/v1/accounts/captain/assistant_responses_controller_spec.rb
spec/enterprise/models/captain/assistant_response_spec.rb`. All 19
examples passed.
- Ran the two new account isolation examples against the original code.
Both failed and reproduced the create and update issue. Both pass with
this fix.
- Ran RuboCop on the five changed Ruby files. It found no offenses.

## 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 fix is effective
- [x] New and existing unit tests pass locally with my changes
2026-08-12 17:48:52 +05:30
Tanmay Deep Sharma
1e17cbe0e7 feat(voice): transcribe Twilio call recordings (#15241)
Twilio voice calls now get an AI transcript alongside the recording.
Once a call ends and its recording is stored, we transcribe it and show
the text under the audio player in the call bubble — the same experience
WhatsApp voice notes already have. Transcription runs on Captain and
consumes Captain response credits, so it only kicks in for accounts with
Captain enabled and audio transcriptions turned on.

## How to test

1. On an account with Captain enabled and Settings → Account → Audio
transcriptions on, make a call on a Twilio voice inbox and hang up.
2. Open the conversation. The voice call bubble shows the recording
player once Twilio delivers the recording.
3. Shortly after, the transcript appears under the player — no refresh
needed.
4. Turn audio transcriptions off (or exhaust Captain credits) and
repeat: the recording still appears, the transcript does not.

## What changed

- `Llm::SpeechToTextService` (new) — blob-in/text-out transcription
engine extracted from `Messages::AudioTranscriptionService`: size limit,
temp-file download, model resolution via `Llm::FeatureRouter`, the
OpenAI call, and Captain credit accounting. `.available_for?` holds the
shared gate.
- `Messages::AudioTranscriptionService` — now a thin wrapper over that
engine; its public contract is unchanged, so
`Captain::OpenAiMessageBuilderService` is unaffected.
- `Voice::CallTranscriptionService` / `Voice::CallTranscriptionJob`
(new) — transcribe `call.recording` into `calls.transcript`, then
rebroadcast the message so clients pick it up over the wire.
- `Voice::Provider::Twilio::RecordingAttachmentService` — enqueues the
job after the recording is attached.

The API and frontend needed no changes: `calls.transcript` already
existed, `_call.json.jbuilder` already serialized it, and
`VoiceCall.vue` already fed it to the audio chip. Nothing had ever
written the column.

Also wires `instrument_audio_transcription`, which existed but was never
called, so both transcription paths now emit LLM spans.
2026-08-12 16:54:49 +05:30
Shivam Mishra
a24f5a3e7a fix: anchor contact phone number validation (#15415)
Contact phone numbers with stray text in front of them, like
`abc+12312312321`, were saving successfully instead of being rejected as
invalid. Agents could end up with unusable numbers on a contact, and the
same values were persisted rather than discarded when captured through
the live chat widget.

## How to reproduce

1. Open a contact and edit its details.
2. Set the phone number to `abc+12312312321` via the API (`PATCH
/api/v1/accounts/:id/contacts/:id`).
3. Before this change the update succeeds. Now it fails validation.

## What changed

The E.164 format check was missing a leading `\A` anchor, so Rails
matched it anywhere in the string and accepted any prefix ahead of a
valid number. Both the validation and the `phone_number_format` fallback
used by `discard_invalid_attrs` are now anchored, so the widget path
discards these values instead of storing them.

Contacts already holding a prefixed number will now fail validation on
their next save. Worth a count on production first:

```sql
SELECT count(*) FROM contacts WHERE phone_number !~ '^\+[1-9][0-9]{1,14}$' AND phone_number <> '';
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-12 15:28:49 +05:30
Muhsin Keloth
79405f76c4 feat(whatsapp): display click-to-chat ad referrals (#15424)
WhatsApp click-to-chat ad referrals are already stored on incoming
messages, but agents only see the customer’s text. This displays the
originating ad preview directly in the message bubble, including
available media, headline, body, and source link. Attribution
identifiers remain hidden.

### Things to know

- The card renders only when `content_attributes.referral` is present.
- Invalid or non-HTTP media and source URLs fall back safely.
- Existing messages without referral metadata are unchanged.

### How to test

1. Open a WhatsApp conversation containing an incoming message with
`content_attributes.referral`.
2. Confirm the ad preview appears above the customer text.
3. Confirm the source opens in a new tab and missing or invalid media
does not break the message bubble.


Fixes
https://linear.app/chatwoot/issue/CW-6206/add-whatsapp-ad-preview-support-in-chatwoot-inbox

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-12 13:28:33 +05:30
Sivin Varghese
541f91acae fix: allow resizing the reply editor while a Copilot suggestion is active (#15391) 2026-08-12 12:42:31 +05:30
Sivin Varghese
5a02616189 fix: inline editor in the contact sidebar clips longer values (#15403) 2026-08-12 12:32:31 +05:30
Devi R
8397fa7eaf fix: include account_id in FCM push payload (#15260)
## Description

- fcm_push_data didn't include the account id, so mobile clients had no
way to know which account a push notification belonged to. This adds
account_id to the FCM payload (it was already present in push_event_data
for ActionCable, just not FCM).
- Prerequisite for the mobile-side fix for
[chatwoot-mobile-app#1121](https://github.com/chatwoot/chatwoot-mobile-app/pull/1121)
(opening a conversation from a notification for a non-active account).
- Added a spec asserting fcm_push_data includes the account id.

Fixes
[CW-4235](https://linear.app/chatwoot/issue/CW-4235/the-conversation-fails-to-open-when-the-notifications-account-differs)

## Type of change

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

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-08-11 20:10:51 -07:00
Sivin Varghese
7ed7bdcef4 fix: close editor pickers when the composer state is rebuilt (#15396)
# Pull Request Template

## Description

This PR fixes the mention picker staying open when switching between
Reply and Private Note, or when switching conversations while a picker
is open. The picker could keep a stale range from the previous editor
state, and selecting an agent from it would crash the editor with
`RangeError: Position N out of range`.

The pickers now close whenever the editor content is rebuilt, and the
stored suggestion range is cleared before creating the new editor state.

This is a pre-existing issue and not a regression from the picker PR.

For the mention picker to open, `@` needs to be at the start of a line
or preceded by whitespace, so the repro uses `hello @` instead of
`abc@`.

#### What changed

Rebuilding the editor state creates new plugin instances, so the old
suggestion plugin is removed without calling `onExit`. This leaves the
picker's stored range pointing to a document that no longer exists.
`reloadState` now clears the stored suggestion range and closes all open
pickers before rebuilding the editor state.
This is a pre-existing issue and not a regression from the picker PR.

Fixes
https://linear.app/chatwoot/issue/CW-7919/mention-picker-opens-on-an-empty-private-note-and-crashes-when-an

## Type of change

- [x] Breaking change (fix or feature that would cause existing
functionality not to work as expected)

## How Has This Been Tested?


### Steps to reproduce

1. Open a conversation and stay in Reply mode.
2. Type `hello @` to open the mention picker.
3. Switch to Private Note.
4. Click any agent from the still-open picker.
5. The editor crashes with `RangeError: Position N out of range`.
6. The same issue can be reproduced by switching to another conversation
while a picker is open.





## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-11 19:42:23 -07:00
Baptiste Fontaine
07352f5ca0 chore(deps): bump lettersanitizer from 1.0.6 to 1.0.8 (#14959)
# Pull Request Template

## Description

This PR bumps lettersanitizer to 1.0.8, because that release includes a
[patch][1] that fixes an [issue][2] affecting Chatwoot: in some cases,
if you receive a malformed email with deeply-nested tags considered
unvalid by `lettersanitizer`, just selecting the email in your inbox
causes the browser tab or even the whole computer to freeze, due to RAM
exhaustion.

[1]: https://github.com/mat-sz/lettersanitizer/pull/10
[2]:
https://github.com/mat-sz/lettersanitizer/security/advisories/GHSA-hhw2-373g-hx77

Edit: also bump `vue-letter` to 0.2.2 to update its own
`lettersanitizer` dependency
(https://github.com/mat-sz/vue-letter/pull/3), per
https://github.com/chatwoot/chatwoot/pull/14959#discussion_r3546868353.

## Type of change

Please delete options that are not relevant.

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

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## 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
- [ ] 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-08-11 19:40:42 -07:00
Sony Mathew
8842c71a90 fix: Handle NUL bytes in canned response search (#15397)
Canned response search now removes NUL bytes from user-provided search
text before passing it to PostgreSQL, so malformed input returns normal
search results instead of a database encoding error.

## Closes

-
[CW-7922](https://linear.app/chatwoot/issue/CW-7922/harden-backend-paths-causing-production-sentry-errors)
- [Sentry 7663466064](https://chatwoot-p3.sentry.io/issues/7663466064/)

## How to reproduce

Call the canned responses endpoint with a search parameter containing a
NUL byte. PostgreSQL previously raised `PG::UntranslatableCharacter`
while evaluating the search query.

## What changed

- Strip NUL bytes once at the controller boundary.
- Reuse the sanitized value for matching and result ranking.
- Add request coverage for a search term containing a NUL byte.
2026-08-11 19:14:10 -07:00
dependabot[bot]
10ea370c07 chore(deps): bump dompurify from 3.4.11 to 3.4.13 (#15370)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to
3.4.13.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/cure53/DOMPurify/releases">dompurify's
releases</a>.</em></p>
<blockquote>
<h2>DOMPurify 3.4.13</h2>
<ul>
<li>Fixed an issue with hook removal during <code>IN_PLACE</code>
sanitization, thanks <a
href="https://github.com/koyokr"><code>@​koyokr</code></a></li>
<li>Fixed an issue with hooks potentially bypassing the clone guard,
thanks <a
href="https://github.com/AkshayjainG"><code>@​AkshayjainG</code></a></li>
<li>Fixed an issue with DOM clobbering via <code>ownerDocument</code>
during <code>IN_PLACE</code>, thanks <a
href="https://github.com/AkshayjainG"><code>@​AkshayjainG</code></a></li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.12</h2>
<ul>
<li>Fixed an issue where a hook would not get called for custom
elements, thanks <a
href="https://github.com/Rikuxx0"><code>@​Rikuxx0</code></a></li>
<li>Hardened the handling of hooks removing elements, <a
href="https://github.com/mkrause-bee360"><code>@​mkrause-bee360</code></a></li>
<li>Added support for a few new SVG attributes, thanks <a
href="https://github.com/cbn-falias"><code>@​cbn-falias</code></a> &amp;
<a
href="https://github.com/Develop-KIM"><code>@​Develop-KIM</code></a></li>
<li>Hardened the handling of declarative partial updates</li>
<li>Updated the documentation is several spots, README, wiki, etc.</li>
<li>Bumped several dependencies where possible</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="3067f77467"><code>3067f77</code></a>
release: 3.4.13 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1562">#1562</a>)</li>
<li><a
href="a9ca1e5374"><code>a9ca1e5</code></a>
release: 3.4.12 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1537">#1537</a>)</li>
<li>See full diff in <a
href="https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.13">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=dompurify&package-manager=npm_and_yarn&previous-version=3.4.11&new-version=3.4.13)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-08-11 18:59:46 -07:00
Marco Cabral
eaecc43ca4 fix: search returning 500 when a conversation has no messages (#15328)
## Description

`SearchService#filter_conversations` matches conversations on the
display id and on the contact name, email, phone number and identifier.
It never looks at message content, so a conversation with no messages is
a valid result whenever its contact matches.

The search views did not account for that. They rendered
`conversation.messages.try(:first)`, which is `nil` for such a
conversation, and `api/v1/models/_message` calls `message.id` on it:

```
ActionView::Template::Error (undefined method 'id' for nil):
    1: json.id message.id
app/views/api/v1/models/_message.json.jbuilder:1
app/views/api/v1/accounts/search/_message.json.jbuilder:1
app/views/api/v1/accounts/search/conversations.json.jbuilder:8
```

A single conversation without messages is enough to turn the whole
search request into a 500 for that query, so the agent loses
conversation search entirely until that conversation gets a message.

Both views that render a conversation search result were affected, so
this applies to `GET /search/conversations` and to the combined `GET
/search`.

The fix guards the message partial the same way the neighbouring
`contact`, `inbox` and `agent` partials in those same views are already
guarded. When there is no message the key is rendered as an empty
object, which is what already happens for a missing contact, inbox or
assignee.

**How to reproduce**

1. Create a conversation without any message (for example via `POST
/api/v1/accounts/{id}/conversations` without a `message`).
2. Search for the contact's name or phone number: `GET
/api/v1/accounts/{id}/search/conversations?q=<phone>`.
3. The request returns 500.

## Type of change

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

## How Has This Been Tested?

Added one spec per affected endpoint in
`spec/controllers/api/v1/accounts/search_controller_spec.rb`, each
creating a conversation with no messages whose contact matches the query
and asserting that it is returned. Both fail with a 500 before the
change.

Also reproduced manually on a running instance: searching a contact that
had a conversation with no messages returned 500, and returns 200 with
`"message": {}` after the change.

## Checklist:

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

---

Related: #15289 documents the atomic `POST /conversations` with an
inline `message`, which avoids creating conversations without messages
in the first place. This fix is independent of it — it protects the
search regardless of how the conversation ended up without messages
(created by an agent before replying, by an API integration, by campaign
tooling, or by a flow that did not complete).

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-08-11 18:34:21 -07:00
Muhsin Keloth
a4ce4b1dc3 fix(contacts): enqueue avatar sync after commit (#15402)
Contact avatars could be skipped when an avatar job was enqueued while
the contact record was still inside an open database transaction. A fast
worker could pick up the job before the record became visible and
discard it after deserialization failed.

This change makes avatar URL jobs wait for the surrounding transaction
to commit before they enter the queue. Avatar jobs scheduled outside a
transaction continue to enqueue immediately, and the existing download,
validation, and rate-limiting behavior remains unchanged.

Fixes
https://linear.app/chatwoot/issue/CW-7917/facebook-contact-avatars-never-sync-avatarfromurljob-is-enqueued

### How to reproduce

1. Receive a Facebook message from a sender who does not yet exist as a
contact.
2. Let the message flow create the contact and schedule its avatar job
within the same transaction.
3. Observe that a worker can attempt to deserialize the contact before
the transaction commits, causing the avatar job to be discarded.

### How to test

1. Receive a Facebook message from a new sender with a profile picture.
2. Confirm the contact and conversation are created successfully.
3. Confirm the avatar job is enqueued after the transaction commits and
the profile picture is attached.
4. Create a contact with an avatar through the Contacts API and confirm
the same behavior.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-11 17:13:11 -07:00
Sivin Varghese
3f4d28f77b feat: add search and preview to the mention, variable and emoji pickers (#15375)
# Pull Request Template

## Description

This PR adds search and preview support to the mention, variable, and
emoji pickers, bringing them in line with the canned response picker.
All editor pickers now use the same component with their own search
field, keyboard navigation, caret-anchored placement, and a preview
pane.

Variables show their description and the value resolved for the current
conversation, emojis show the glyph, name, shortcode, and category, and
mentions show relevant agent or team details such as availability, role,
auto-assign, and membership.

This also fixes company custom attributes incorrectly appearing as
contact custom attributes in the variable picker. They could create
duplicate entries and insert variables that always resolved to an empty
value, so they are now excluded.

The old `MentionBox` component has also been removed since the variable
and emoji pickers were its only remaining users.

Fixes
https://linear.app/chatwoot/issue/CW-7854/inconvenient-canned-response-picker-and-lack-of-personal-canned

## Type of change

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


## How Has This Been Tested?

### Screenshots
**Emoji Picker**
<img width="1302" height="527" alt="image"
src="https://github.com/user-attachments/assets/a491b149-d547-4ab4-b990-a054faf38ac1"
/>

**Variables Picker**
<img width="1305" height="487" alt="image"
src="https://github.com/user-attachments/assets/42a6d334-4f70-47e2-ada4-531cf8619454"
/>
<img width="444" height="556" alt="image"
src="https://github.com/user-attachments/assets/bccd15e1-e5af-4362-b1b5-27877910670c"
/>




**Tag agents/teams Picker**
<img width="1308" height="502" alt="image"
src="https://github.com/user-attachments/assets/547d9142-a6e5-4606-a9b7-01e824868b29"
/>
<img width="1308" height="502" alt="image"
src="https://github.com/user-attachments/assets/28cd4919-5875-462a-adc8-90c0fd8e8a3f"
/>
<img width="444" height="556" alt="image"
src="https://github.com/user-attachments/assets/33eaa131-c033-49e4-aa93-343c2a976d5b"
/>








### Steps

1. Type `@` in a private note. Verify agents and teams appear under
separate headings, and the preview shows availability and role for an
agent, and auto-assign and membership status for a team.
2. Type `{{` in a reply. Verify the preview shows the variable
description and the value resolved for the current conversation. Also
verify that a variable with no value clearly indicates that no value is
available.
3. Verify company custom attributes no longer appear in the variable
picker.
4. Type `:` followed by at least two characters. Verify emojis can be
searched by name and shortcode, and the preview shows the glyph, name,
and category.
5. For each picker, navigate with the arrow keys or Tab, insert with
Enter or a click, and close with Escape.
6. Reopen a conversation with a draft containing `@name`, `/shortcode`,
or `{{contact`. Verify the corresponding picker opens with the existing
text populated in its search field.



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-11 18:56:52 +05:30
Sivin Varghese
7cba8a01bd feat: add search to filter dropdowns and group attributes (#15348) 2026-08-11 18:48:55 +05:30
Vishnu Narayanan
e6f21f7c99 perf: scale sidekiq worker MemoryMax with host memory (#15412)
## Description

This systemd unit is installed on every self-hosted Linux install
(copied by `deployment/setup_18.04.sh` / `setup_20.04.sh`) as well as
our own worker hosts, so the memory cap has to be correct across a wide
range of box sizes.

It is currently a fixed `MemoryMax=1.2G`, chosen when workers ran on 2GB
hosts (#12915). On larger hosts that fixed cap sits at a high fraction
of the limit under normal load, so transient spikes hit the hard limit
and the worker is OOM-restarted more often than necessary (each restart
drops the jobs in-flight at that instant).

Switching to `MemoryMax=60%` makes the cap scale with the host's
physical memory instead of being fixed:

- 2GB host: 60% = 1.2G, identical to today (no regression for small
installs)
- 4GB host: 60% = ~2.4G, room to absorb spikes without a premature
restart
- larger hosts scale proportionally

`MemoryHigh=infinity` is unchanged, so the worker is still never
throttled by cgroup reclaim (kept intentionally since #12871 to avoid
reclaim-induced worker stalls). Only the hard cap changes, and only its
scaling.

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

## Type of change

- [x] Performance (non-breaking change which improves performance)
2026-08-11 15:46:05 +05:30
Shivam Mishra
875ea6e88f fix(auth): persist MFA sessions across browser restarts (#15379)
MFA-authenticated users now remain signed in across browser restarts,
matching the session lifetime of password-authenticated users.

## Closes

-
[CW-7898](https://linear.app/chatwoot/issue/CW-7898/mfa-users-are-logged-out-when-the-browser-session-ends)

## How to reproduce

1. Enable MFA for a user.
2. Sign in and complete OTP or backup-code verification.
3. End the browser session, then reopen the browser and return to
Chatwoot.
4. Before this change, the user is sent back to the login page.

## Root cause and evidence

Before this change, successful MFA verification wrote
`cw_d_session_info` directly through `document.cookie` without an
`Expires` or `Max-Age` attribute. This makes it a session cookie:

- [MFA cookie creation on the base
commit](a4eae9710a/app/javascript/dashboard/components/auth/MfaVerification.vue (L70-L87))
- [MDN session-cookie
behavior](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie)
- [Chromium cookie persistence
documentation](https://chromium.googlesource.com/chromium/src/+/master/net/cookies/README.md)

Password login already uses `setAuthCredentials`, which derives a
persistent cookie expiry from the authentication response:

- [Existing credential
helper](a4eae9710a/app/javascript/dashboard/store/utils/api.js (L28-L36))
- [Configured two-month token
lifetime](a4eae9710a/config/initializers/devise_token_auth.rb (L8-L10))

This change routes successful MFA verification through the same
credential helper and existing verified event. The regression test
asserts that MFA credentials use this shared persistence path.

Session-cookie restoration is browser and profile dependent. Chromium
may persist session cookies when restoring a previous browsing session,
so the problem can be masked on some restarts. This PR does not rely on
a desktop-versus-mobile distinction.

## How to verify

1. On `develop`, complete an MFA login.
2. Inspect `cw_d_session_info` in browser developer tools. Its expiry is
shown as `Session`.
3. Repeat on this branch.
4. Confirm that `cw_d_session_info` has a concrete expiry derived from
the authentication response instead of `Session`.
2026-08-11 15:03:49 +05:30
Shivam Mishra
0a2293f921 feat: record Captain conversation outcome episodes from lifecycle events [CW-7792] (#15316)
Records Captain conversation outcomes at episode grain so reporting can
distinguish initial demand from reopened conversations and measure
replies, handoffs, resolutions, human follow-up, and CSAT.

Eligibility creates the episode at demand time. Message-derived fields
are snapshotted from persisted messages at handoff or resolution,
keeping terminal analytics accurate without writing outcomes for every
message. Outcome tracking remains reporting-only and fail-open.

Builds on the episode-grain schema from #15315.

## Closes

- https://linear.app/chatwoot/issue/CW-7792

## How to test

1. Enable `captain_integration_v2` and connect a Captain assistant to an
inbox.
2. Send an inbound customer message and confirm an initial outcome
episode is created at the message timestamp.
3. Let Captain reply and then resolve or hand off the conversation.
Confirm the episode records Captain reply counts and timestamps, the
outcome timestamp, and the handoff category where applicable.
4. Reply after resolution and confirm a `reopen` episode is created
while preserving the previous episode.
5. Resolve the reopened conversation and submit CSAT. Confirm the
response is attributed to the episode that issued the survey.

## What changed

- Creates the initial episode from demand-level eligibility and appends
a new episode when a resolved conversation reopens.
- Snapshots Captain replies and the first qualifying human reply from
persisted messages at handoff and resolution.
- Attributes asynchronous resolution events using the episode active at
the event timestamp.
- Records later CSAT responses using the survey message timestamp.
- Keeps boundary writes transactional and fail-open without retries,
advisory locks, late-boundary repair, or handoff self-healing.
- Adds schema-constrained handoff reason categories, including lifecycle
coverage for incomplete V2 tool fallback handoffs.

Open, non-terminal episodes may retain empty or stale message-derived
fields until handoff or resolution.
2026-08-11 14:57:23 +05:30
Aakash Bakhle
1693525124 fix(captain): scope copilot conversation access (#15249)
## Description

Captain Copilot's `get_conversation` tool returned any conversation in
the account, without checking whether the agent asking for it could open
that conversation from the inbox view. An agent who belongs to a single
inbox, or who holds a narrow custom role, could therefore read the
message history of conversations outside their access, including the
private notes on them.

The tool now runs the same permission filter that the conversation list
endpoint and Copilot's own `search_conversation` tool already use, so it
returns only the conversations the agent can already open.
Administrators see no change, because the filter returns the whole
account scope for them.

The Copilot chat service had the same gap. When an agent opened Copilot
while viewing a conversation, the service looked that conversation up by
account alone and wrote its ID and contact ID into the system prompt. It
now resolves the conversation through the same filter, and leaves the
context out when the agent cannot access it.

## Closes

https://linear.app/chatwoot/issue/CW-7768

## Type of change

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

## How to reproduce

1. Create two inboxes in one account, for example Inbox A and Inbox B.
2. Add an agent to Inbox A only, then start a conversation in Inbox B
and leave a private note on it.
3. Sign in as that agent, open Copilot, and ask it for the Inbox B
conversation by its ID.
4. Before the change Copilot returns the full message history including
the private note. After the change it reports that the conversation was
not found.

Opening the same conversation from the inbox view as that agent is
rejected both before and after the change, so the inbox view and Copilot
now agree on what the agent can read.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-11 14:49:59 +05:30
Tanmay Deep Sharma
406eb8f650 feat(automation): allow send email transcript action to target the contact's email (#15373)
Automations that send an email transcript now let you insert the
conversation contact's email directly into the recipient field, instead
of only accepting a hardcoded address typed in by hand.

## Closes

https://linear.app/chatwoot/issue/CW-7535/allow-automation-to-send-conversation-transcripts-to-user-emails

## How to test
1. Go to **Settings → Automation → Create Automation** (or edit an
existing rule).
2. Add the **Send an Email Transcript** action.
3. Click **Use contact's email** next to the recipient field — it
inserts `{{contact.email}}` into the field, appending to any address
already typed rather than replacing it.
4. Save the rule and trigger it on a conversation with a contact that
has an email address; confirm the transcript is delivered to that
contact.

## What changed
- `AutomationActionInput.vue`: added a "Use contact's email"
quick-insert action next to the recipient field for the
`send_email_transcript` action, and switched the input from
`type="email"` to `type="text"` so the dynamic token isn't fought by
native email-format validation.
- No backend changes were needed — `send_email_transcript` already
resolves `{{contact.email}}` via the existing Liquid templating support
in `ActionService#send_email_transcript`; this change only exposes that
capability in the UI.
- This also benefits the Macros editor, which shares the same input
component.

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-11 10:44:27 +05:30
Sivin Varghese
2fbcc715ce fix: conversations hidden when filtering by a date range (#15385) 2026-08-11 10:08:10 +05:30
Muhsin Keloth
66067a1dfe fix(inboxes): show facebook inbox creation errors (#15408)
Facebook inbox creation failures in Settings could leave users on the
same screen without explaining what went wrong. This change preserves
the backend error response and displays it through the existing toast
mechanism, making account-limit and other creation failures visible and
actionable.

The shared action now passes the original API error through, so the
newer onboarding flow also receives the specific backend message.
Successful Facebook inbox creation remains unchanged.

### How to reproduce

1. Use an account that has reached its inbox limit.
2. Go to Settings → Inboxes → Add Inbox → Facebook.
3. Select a Facebook Page and create the inbox.
4. Previously, the loading state ended without any visible error.

### How to test

1. Attempt Facebook inbox creation while the backend returns an HTTP 402
account-limit error.
2. Confirm the returned error appears as a toast message.
3. Retry after increasing the account limit and confirm inbox creation
continues normally.

### Things to know

The toast falls back to a generic localized creation error when the API
response does not contain a displayable message.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-11 09:31:40 +05:30
Sojan Jose
c70c87c315 fix(widget): correct Hebrew availability card (#15405) 2026-08-11 09:04:31 +05:30
Captain
9a9be2f919 chore: Update translations (#15355)
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-08-10 12:07:39 -07:00
Shivam Mishra
972b69273b refactor(saml): harden multi-account user handling (#15395)
Improves SAML user handling for users associated with multiple accounts.
Restricts cross-account invitations and skips provider updates for
multi-account users.
Aligns SAML authentication and provider reset behavior with these
eligibility rules.
2026-08-10 21:56:36 +05:30
Sivin Varghese
cce94aa936 chore: rework the canned response picker with search and preview (#15354)
# Pull Request Template

## Description

This PR reworks the canned response picker to make it easier to search,
browse, and preview canned responses before inserting them. Typing `/`
now opens a larger picker with its own search field and a preview pane.
Previously, only a few responses were visible at a time, there was no
way to preview the full content, and searching relied on typing into the
composer, which stopped working for multi-word queries.

Search is now handled entirely inside the picker, so the composer stays
untouched while searching. Results match both the canned response
shortcut and its content, and each result shows a snippet centered
around the matched text instead of always displaying the beginning of
the response. The preview renders the response exactly as it will be
inserted, with variables resolved against the current conversation and
formatting unsupported by the channel already stripped.

The picker is positioned relative to the current typing line and
teleported to `body`, so it is no longer clipped by the composer. It
behaves consistently across the reply editor, the New Conversation
composer, and narrower editors such as Contact Notes, where the preview
moves below the list instead of disappearing.

This also fixes a pre-existing bug where variables without a value were
removed from the inserted text instead of being left for the backend to
resolve. In the New Conversation composer, where no variables are
available, all `{{ }}` placeholders were previously being silently
removed.

Fixes
https://linear.app/chatwoot/issue/CW-7854/inconvenient-canned-response-picker-and-lack-of-personal-canned

## Type of change

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

## How Has This Been Tested?

### Screenshots
<img width="1135" height="576" alt="image"
src="https://github.com/user-attachments/assets/b5f27b94-eeb5-4ac6-b6d9-da7dfd8c2306"
/>
<img width="393" height="490" alt="image"
src="https://github.com/user-attachments/assets/779f88b1-4958-41f5-9f51-c2eb8db7e53c"
/>



### Steps

1. Open a conversation and type `/` in the reply editor.
2. Search using a multi-word phrase that appears within a canned
response. Verify the matching response appears with a snippet centered
around the matched text.
3. Navigate the results with the arrow keys or Tab and verify the
preview updates.
4. Press Enter or click a response to insert it, and press Escape to
close the picker.
5. Repeat in a narrow editor such as Contact Notes and verify the
preview pane moves below the list.
6. In the New Conversation composer, insert a canned response containing
variables and verify the `{{ }}` placeholders are kept rather than
removed.



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-10 19:51:30 +05:30
Aakash Bakhle
bdbbaa38de feat(captain): add inactivity timer backend (2/5) (#15303)
Captain V2 assistants can now persist a configurable inactivity timer
and choose whether inactivity resolution sends the saved closing message
or resolves silently. This PR contains only the API, persistence,
runtime behavior, and backend specs.

## Closes

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

## Depends on

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

## What changed

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

## How to test

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-10 17:33:04 +05:30
Muhsin Keloth
9a9c88494e feat(whatsapp): improve template listing controls (#15377)
Improves the WhatsApp template listing so account admins can understand
template state and refresh templates from one place. Templates that have
not entered the WhatsApp approval flow now use clearer wording, the
latest sync attempt appears once at page level, and templates can be
filtered by content type.

The page can now start a template sync across all active WhatsApp
inboxes in the account and reports complete or partial failures. The New
template action is removed for now; provider-side template management
remains unchanged.

### Related

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

### How to test

1. Open **Settings → Templates** in an account with WhatsApp templates.
2. Confirm the latest sync attempt appears below the page description
and is no longer repeated on every template.
3. Confirm an unsubmitted template displays **Not submitted for WhatsApp
approval**.
4. Filter templates by content type and verify the list updates.
5. Click **Sync templates** and confirm sync starts for the account's
active WhatsApp inboxes.
6. Confirm the **New template** button is not displayed.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-10 16:14:40 +05:30
Sojan Jose
81fc35e9e6 chore: upgrade Rails to 7.2.3.1 (#13437)
This upgrades Chatwoot to Rails 7.2.3.1 while retaining the current
Rails 7.0 framework defaults, so the runtime upgrade can be deployed and
observed independently from default-behavior changes.

## What changed

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

## How to test

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

## Rollout

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

## Tracking

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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-08-10 15:49:38 +05:30
Aakash Bakhle
a4eae9710a test: Add focused Captain response lifecycle logs (#15364)
## What changed

This pull request adds three focused log emitters for Captain V2
response jobs:

* `job_dequeued` when Sidekiq fetches the job from Redis
* `job_skipped` when the conversation is not Pending at the first job
guard
* `response_discarded` when a newer customer message exists before model
generation

The dequeue middleware identifies V2 jobs by the triggering message ID
in the third serialized Active Job argument. It does not log Captain V1
or other Sidekiq jobs.

## Why

Recent production incidents have an enqueue record but no later job
record. Active Job `Performing` and `Performed` logs are now available
temporarily, but they do not show whether Sidekiq fetched a job before a
worker disappeared.

The dequeue log closes that gap. The two application logs explain the
early exits that otherwise produce no model trace.

The production root cause remains unresolved. This pull request adds
evidence for the next occurrence and does not change Captain response
behavior.

## Log volume

A Captain V2 response job adds one dequeue line. The other two lines
occur only on an early status skip or a pre-generation burst discard.
Existing Active Job, Langfuse, completion, failure, handoff, and usage
logs cover later stages.

## Validation

* Ruby syntax checks passed for the four implementation files.
* RuboCop found no offenses in the four implementation files.
* No new specs were added because this is temporary diagnostic logging
with no response behavior change.
2026-08-09 09:01:03 +05:30
Sojan Jose
f12529105b fix: align AgentBot ownership with conversation counts (#15343)
AgentBot-owned conversations are now treated as assigned across
conversation lists, counts, pagination, permissions, unread membership,
human auto-assignment, and advanced assignee filters.

## Closes

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

## Follow-ups

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

## Why

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

## What changed

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

## Validation

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

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

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

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

## Validation
- Assign an open conversation to an Agent Bot through the assignment API
and verify it becomes pending.
- Assign that bot-owned pending conversation to a human and verify it
becomes open.
- Verify ordinary human assignment, non-pending bot takeover, and
unassignment do not force a status change.
2026-08-07 12:08:58 -07:00
Sivin Varghese
f770c585bd fix: prevent vertical tab labels from overlapping (#15363) 2026-08-07 22:18:46 +05:30
Muhsin Keloth
c8e95efb4c fix(whatsapp): return empty template arrays for malformed inbox data (#15356)
WhatsApp inbox responses now return an empty template list when the
stored `message_templates` value is not an array. This prevents mobile
clients from receiving the legacy/default object shape that can crash
the WhatsApp reply box.

Valid template arrays remain unchanged.

Related: https://github.com/chatwoot/chatwoot-mobile-app/pull/1127

### Things to know

- No database migration or backfill is included.
- This does not change the web template-button behavior; that can be
handled separately.

### How to reproduce

1. In a local environment, set a WhatsApp inbox's `message_templates`
value to `{}`.
2. Load the inbox API response and open one of its conversations in the
mobile app.
3. Confirm the response contains `message_templates: []` and the reply
box does not crash.
4. Repeat with a valid template array and confirm the templates are
preserved.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-07 15:23:19 +05:30
Pranav
0f3bb640f5 feat(captain): Add audience and schedule controls for assistants (#14902)
Captain assistants now support **audience** and **schedule** controls,
so you can decide *who* an assistant replies to and *when* it's on duty.
By default nothing changes, an assistant still responds to every
conversation in its connected inboxes but you can now narrow that down.

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

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

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

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

## How to test

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

---------

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

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

## How to reproduce

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

## What changed

`ConversationFinder#filter_by_conversation_type` **replaced**
`@conversations` with `current_user.participating_conversations` for the
`participating` type, discarding the inbox/permission-filtered scope
built up by `Conversations::PermissionFilterService` immediately before
it. It now narrows the existing scope by participating ids instead, so
permission filtering survives.
2026-08-06 19:00:30 +05:30
Sony Mathew
e3e35ab7e1 feat: enable data imports for paid plans (#15346)
Data Imports is now part of the shared paid-plan entitlement set.
Startups, Business, and Enterprise accounts receive the feature through
billing reconciliation, while Hacker/default accounts remain gated and
the existing API/UI feature checks stay unchanged.

### Closes

-
[CW-7878](https://linear.app/chatwoot/issue/CW-7878/enable-data-imports-for-all-paid-cloud-plans)

## Type of change

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

## How Has This Been Tested?

1. Reconcile a Hacker account and confirm Data Imports remains disabled.
2. Reconcile Startups, Business, and Enterprise accounts and confirm
Data Imports is enabled for each paid tier.
3. Exercise the Stripe subscription update path and confirm the same
plan hierarchy is applied.

## Rollout

Existing paid accounts need a one-time reconciliation after deployment.
Run the following in the Rails console:

```rb
paid_plan_names = InstallationConfig.find_by!(name: 'CHATWOOT_CLOUD_PLANS').value.drop(1).pluck('name')
paid_accounts = Account.where("custom_attributes ->> 'plan_name' IN (?)", paid_plan_names)

puts "Reconciling #{paid_accounts.count} paid accounts"

paid_accounts.find_each do |account|
  Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
end
```

Future plan changes and subscription renewals are handled by the normal
Stripe reconciliation path.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-06 17:26:12 +05:30
Tanmay Deep Sharma
473ac39489 feat: nudge users with a dashboard banner when backup codes run low (#14103)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-6884/nudge-users-with-a-dashboard-banner-when-2fa-backup-codes-run-low

## Description

Shows a dashboard-wide banner when the signed-in user has 3 or fewer
unused backup codes left (amber), turning to an alert style at 0
remaining. Clicking "Generate codes" takes the user to the MFA settings
page so they can regenerate codes before they get locked out. Inspired
by Google's post-backup-code-use nudges.

## How to test

1. Sign in as a user with MFA enabled.
<img width="1512" height="824" alt="Screenshot 2026-08-05 at 4 52 15 PM"
src="https://github.com/user-attachments/assets/08138f3e-cc15-451e-bbcc-7772dc2a875c"
/>
<img width="1507" height="701" alt="Screenshot 2026-08-05 at 4 54 02 PM"
src="https://github.com/user-attachments/assets/580051fa-cbb5-47c1-81a3-258b7c5b5a03"
/>


2. In a Rails console, simulate a low state by marking most backup codes
as used:
   ```ruby
   u = User.find_by(email: '<your user>')
   codes = u.otp_backup_codes.dup
   (0...8).each { |i| codes[i] = 'XXXXXXXX' }
   u.otp_backup_codes = codes
   u.save!
   ```
3. Reload any dashboard page — the amber banner should appear with a
"Generate codes" CTA.
4. Click the CTA — it should route to **Profile → Two-Factor
Authentication**, where you can regenerate codes.
5. Set the count to 0 (mark all 10 as `'XXXXXXXX'`) — banner should
switch to the red/alert style.
6. Regenerate codes — banner should disappear on the next dashboard page
load.



## Checklist:

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


> Base is the [disable-with-backup-code PR
branch](https://github.com/chatwoot/chatwoot/pull/14102) so CTAs around
recovery are consistent; rebase onto `develop` once that merges.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-08-06 16:17:58 +05:30
Tanmay Deep Sharma
cefb3fea54 fix(voice): sync inbound WhatsApp call accept state across tabs (#15326)
When an agent has multiple tabs or windows open on the same account, an
inbound WhatsApp call rings in all of them, as expected. But once the
call is answered in one tab, the others never found out — they kept
showing the incoming-call popup and playing the ringtone indefinitely,
as if the call were still waiting to be picked up.

## How to reproduce
1. Log into the same agent account in two browser tabs.
2. Receive an inbound WhatsApp call (rings in both tabs).
3. Accept the call in one tab.
4. The other tab keeps ringing and shows the call as still incoming.

## What changed
- The backend already broadcasts a `voice_call.accepted` event
account-wide when a call is answered, but the dashboard never had a
listener registered for it — the event was silently dropped. Added the
missing handler in `actionCable.js` so every tab except the one that
owns the now-active call clears its ringing state.
- Added `root: true` to `.eslintrc.js` so ESLint config resolution stops
at the project root instead of also picking up a parent directory's
config (this repo's git-worktree layout nests worktrees under the main
checkout, which was causing an ambiguous plugin-resolution error for
anyone linting from a worktree).

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-08-06 16:17:30 +05:30
Sivin Varghese
240aca1d7a fix: keep bulk action bar on a single line for longer translations (#15339) 2026-08-06 14:59:52 +05:30
Vishnu Narayanan
17d927554d fix: block the unused Active Storage direct-upload route (#15329)
## Description

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

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

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

## Type of change

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

## How Has This Been Tested?

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

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

## Checklist:

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-08-06 14:25:19 +05:30
Sony Mathew
696d2a5d37 fix: add default names to data imports (#15345)
Data imports created without a name now receive a readable default such
as `Contacts - 2026-08-06`. This prevents legacy CSV contact imports
from appearing as `Untitled import` under Settings → Data while
preserving names supplied by users or other import providers.

### Closes

-
[CW-7877](https://linear.app/chatwoot/issue/CW-7877/add-default-names-for-unnamed-data-imports)

## Type of change

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

## How Has This Been Tested?

1. Create a contact CSV import without supplying a name.
2. Open Settings → Data and confirm its name follows `Contacts -
YYYY-MM-DD`.
3. Create an import with an explicit name and confirm that name remains
unchanged.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-06 13:20:56 +05:30
Tanmay Deep Sharma
430c5cfef0 fix(whatsapp): refresh inboxes when opening new conversation composer (#15337)
When an agent starts a new conversation, the WhatsApp template picker in
the composer only shows templates that were already loaded into the
frontend store — it doesn't refresh when the composer opens. If a
template sync completed after the store was last populated, the newly
synced template shows up on the inbox's Settings > Templates page (which
always refetches on load) but not in the New Conversation composer,
since that view relied solely on the account-cache-invalidated websocket
event, which doesn't always reach an already-open session in time.

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

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-08-06 12:26:12 +05:30
Sivin Varghese
78069b01f6 fix: make the changelog card visible in dark mode (#15340) 2026-08-06 10:32:11 +05:30
Sivin Varghese
ce06121587 fix: prevent command bar crash and hide inaccessible commands (#15322) 2026-08-05 19:32:07 +05:30
Sivin Varghese
f2cf81e7ff chore: use SidePanel component for the article diff panel (#15333)
# Pull Request Template

## Description

The unsaved changes panel in the Help Center article editor now uses the
shared `SidePanel` component instead of its own custom drawer. It now
matches the rest of the dashboard, with the same slide-in animation,
backdrop, and close button for a consistent experience.


## Type of change

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

## How Has This Been Tested?

### Screenshots
**Before**
<img width="1530" height="879" alt="image"
src="https://github.com/user-attachments/assets/4ef7eafb-0cd6-455f-a970-e1411a24ef25"
/>


**After**
<img width="1530" height="879" alt="image"
src="https://github.com/user-attachments/assets/be99ee0d-1906-4a49-967b-3ba1f4fa40b6"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-05 17:10:52 +05:30
Sivin Varghese
f58f08a40d fix: prevent avatar upload overlay from showing in conversation list (#15332)
# Pull Request Template

## Description

This PR fixed the conversation list was incorrectly rendering the avatar
upload overlay on every conversation card, even though uploads aren't
supported there. Clicking it could throw a `TypeError: Cannot read
properties of null (reading 'click')`. Conversation cards now only show
the selection checkbox, while avatar uploads continue to work everywhere
they're supported.


### Cause

`ConversationCard` always passed the `#overlay` slot, but the checkbox
inside it was wrapped in `v-if`. When the checkbox wasn't rendered, Vue
treated the slot as empty and fell back to the default upload overlay
from `Avatar`.

That overlay's click handler expects a file input, but the file input is
only rendered when `allowUpload` is enabled. Since conversation cards
never enable uploads, clicking the overlay could dereference a null file
input and throw.

### How to reproduce

This isn't reliably reproducible manually. It only happens when the
upload overlay becomes visible while the card's internal hover state is
out of sync with the browser's CSS `:hover` state. In normal
interaction, entering the card immediately updates the hover state and
shows the checkbox instead, so the issue effectively self-recovers.

The new test reproduces this state directly and verifies the fix.

## What changed

* Moved `v-if="allowUpload"` from the hidden file input to the upload
overlay itself, so the overlay and file input are always mounted
together.
* Added `Avatar.spec.js` coverage for the overlay slot, including the
empty-slot case that triggered this bug, along with the existing upload,
delete, badge, sizing, initials, and image fallback behavior.

Fixes
https://linear.app/chatwoot/issue/CW-7726/typeerror-cannot-read-properties-of-null-reading-click

https://chatwoot-p3.sentry.io/issues/7291677410/?project=4507182691975168&referrer=Linear

## Type of change

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

## How Has This Been Tested?

### Screenshots

**Before**
<img width="1683" height="847" alt="image"
src="https://github.com/user-attachments/assets/1b746be4-0425-4a68-953b-193e70411032"
/>


**After**
<img width="1683" height="847" alt="image"
src="https://github.com/user-attachments/assets/e0c78aba-6053-442c-905a-373c8d2be123"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-05 17:07:26 +05:30
Vishnu Narayanan
a72b49a981 fix: validate days_before filter values before date arithmetic (#15331)
## Description

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

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

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

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

## Type of change

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

## How Has This Been Tested?

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

## Checklist:

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

## Closes


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

## Why

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

## What this change does

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

## Validation

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

---------

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-08-05 16:34:46 +05:30
Muhsin Keloth
343bb15d07 feat(whatsapp): add quick setup access request (#15336)
Cloud accounts without access to WhatsApp Quick setup currently fall
directly into manual configuration, with no explanation of how to
request the easier Meta flow.

This adds a compact access-request card above manual setup. It explains
that Quick setup with Meta can connect either a new number or an
eligible existing number from the WhatsApp Business app, then opens the
existing support widget so the account can be reviewed. The enabled
state now uses the same customer-facing language.

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

<img width="1428" height="1110" alt="CleanShot 2026-08-05 at 14 12
31@2x"
src="https://github.com/user-attachments/assets/914e5fd7-eb5a-438c-9706-0ecd8e004fbf"
/>


### Things to know

- The access card is Cloud-only and appears when the account-level
WhatsApp Quick setup feature is disabled.
- The global Meta incident restriction still takes precedence and
continues to show the existing incident warning.
- Manual setup remains available, and self-hosted behavior is unchanged.
- Requesting access opens Chatwoot support; it does not automatically
enable the account feature.
- Meta documents the existing WhatsApp Business app number path as
coexistence onboarding:
https://developers.facebook.com/documentation/business-messaging/whatsapp/embedded-signup/onboarding-business-app-users

### How to test

1. On Chatwoot Cloud, set `DISABLE_META_INBOX_CREATION` to `false`.
2. Open WhatsApp inbox creation for an account without
`whatsapp_embedded_signup_inbox_creation`.
3. Confirm the Quick setup with Meta request card appears above the
unchanged manual configuration form.
4. Select **Request access** and confirm the support widget opens.
5. Enable `whatsapp_embedded_signup_inbox_creation` for the account and
reload.
6. Confirm the enabled Quick setup with Meta screen appears and
describes both new numbers and eligible existing WhatsApp Business app
numbers.
7. Set `DISABLE_META_INBOX_CREATION` to `true` and confirm the incident
warning appears instead of the access-request card.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-05 15:01:34 +04:00
Muhsin Keloth
0669dc4ea5 fix(whatsapp): hide manual transfer for coexistence inboxes (#15330)
WhatsApp Business app Coexistence inboxes currently see the manual
migration prompt even though they cannot move to the generic manual
setup flow without losing the supported Coexistence path.

This change waits for WhatsApp health data and shows manual transfer
only when Meta confirms the number is not connected to the WhatsApp
Business app. Other eligible Embedded Signup inboxes continue to see the
migration prompt.

### Things to know

- This is frontend-only gating; the migration API behavior is unchanged.
- The prompt remains hidden while the WhatsApp Business app state is
unknown or unavailable.

### How to test

1. Enable `whatsapp_manual_transfer` for an account with an Embedded
Signup WhatsApp inbox.
2. Open an inbox whose health response reports `is_on_biz_app: true`;
confirm the migration banner and dialog are unavailable.
3. Open an inbox whose health response reports `is_on_biz_app: false`;
confirm the migration banner appears and opens the existing manual
migration dialog.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-05 14:57:29 +04:00
Muhsin Keloth
781943867b fix(whatsapp): preserve and display flow responses (#15279)
WhatsApp Flow submissions currently reach Chatwoot as
`interactive.nfm_reply` messages, but their structured answers are
discarded. Agents see an empty message and outbound webhooks cannot
access the submitted data.

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

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


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


### How to reproduce

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

### What changed

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

### How to test

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

---------

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

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

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

Related: #15116

## Closes

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

## Type of change

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

## What changed

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

## How to test

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

## Current scope

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

## Checklist:

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

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

This PR trims both:

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

Fixes https://linear.app/chatwoot/issue/CW-7830
2026-08-05 14:43:51 +05:30
Vishnu Narayanan
d83135721b fix: clear conversation list loading state when filter request fails (#15320)
## Description

When `POST /api/v1/accounts/:id/conversations/filter` fails (for example
a 500 or a server-side timeout), the conversation list spinner never
clears because the Vuex action's catch block was empty. The user is
stuck on an infinite loader with no way to retry.

This change:

- Clears the list loading state (`CLEAR_LIST_LOADING_STATUS`) and
rethrows in `fetchFilteredConversations`, matching how sibling actions
in the module handle errors. Both applied filters and saved filters
(custom views) go through this action.
- Catches the rejection in `ChatList.vue` and shows an alert ("Couldn't
load conversations. Please try again.") so the user can retry.
- Guards the reconnect flow in `ReconnectService` so a failed filtered
fetch on websocket reconnect does not abort cache revalidation.
- Fixes the existing spec for the success path, which passed no
`dispatch` and was silently exercising the error path.

No client-side request timeout was added since the dashboard API layer
has no per-request timeout convention; the fix is scoped to error
handling.

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

## Type of change

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

## How Has This Been Tested?

- Added a spec asserting the loading state clears and the error is
rethrown when the filter request rejects: `pnpm test
app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js`
(49 passed).
- `pnpm test
app/javascript/dashboard/helper/specs/ReconnectService.spec.js` (24
passed).

## Checklist:

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

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

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

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

## Type of change

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

## How Has This Been Tested?

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

## Checklist:

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

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

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

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

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

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

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

## Type of change

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

## How Has This Been Tested?

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

## Checklist:

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

## How to reproduce

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

## What changed

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

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

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

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

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

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

## What changed

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

## Why this approach

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

## Tests

Added specs covering:

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

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

---------

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

## Before

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

## After

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

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

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

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

## Closes

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

## How to test

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

## How to reproduce

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

## What changed

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

## Closes

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

## How to test

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

## What changed

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

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

## How to reproduce

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

## What changed

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

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

A write that would just reapply an already-current value becomes a
genuine no-op instead of producing a redundant `UPDATE` and duplicate
activity message. Legitimate reassignment (a real change in target, or
reassignment away from an agent who lost inbox access) is unaffected.
2026-08-04 17:08:42 +05:30
Muhsin Keloth
950d871830 fix(meta): add independent incident runtime controls (#15318)
This restores the temporary Meta incident safeguards with two
independent runtime controls for Chatwoot Cloud. Super admins can now
re-enable Instagram messaging first and keep new Meta inbox creation
disabled until onboarding is stable.

When messaging is disabled, Instagram conversations show the incident
notice and remain in private-note mode. The separate inbox-creation
control disables Facebook, Instagram, and WhatsApp Embedded Signup entry
points. Self-hosted installations remain unchanged.

Related: https://github.com/chatwoot/chatwoot/pull/15210
Related: https://status.chatwoot.com/incident/976975

### Things to know

- `DISABLE_META_MESSAGE_SENDING` affects Instagram messaging only.
- `DISABLE_META_INBOX_CREATION` affects Facebook, Instagram, and
WhatsApp Embedded Signup inbox creation.
- Both controls default to `true` for the active incident and are
editable from Super Admin installation configs.
- The Cloud-only boundary is enforced by the dashboard configuration
getters.

### How to test

1. Open Super Admin → Installation Configs and set
`DISABLE_META_MESSAGE_SENDING` to `false`.
2. Reload an Instagram conversation and confirm the incident banner
disappears and the runtime restriction no longer forces private-note
mode.
3. Set the flag back to `true`, reload, and confirm the banner and
restriction return.
4. Set `DISABLE_META_INBOX_CREATION` to `false`, reload the Facebook,
Instagram, or WhatsApp Embedded Signup setup flow, and confirm
connection is enabled.
5. Set the flag back to `true`, reload, and confirm the incident notice
appears and connection is disabled.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-08-04 15:13:59 +04:00
Muhsin Keloth
544f0da63c feat(whatsapp): add template listing to settings (#15312)
Account administrators can now browse synced WhatsApp message templates
inside Settings without opening a provider dashboard. The page combines
native WhatsApp and Twilio WhatsApp templates, with channel and language
filters, search, a template preview drawer, and links to manage
templates at the provider.

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

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

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

### Things to know

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

### How to test

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

---------

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

## Why this wasn't in #15233

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

## What changed

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

Non-WhatsApp inbox behavior remains unchanged.

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

### Things to know

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

### How to test

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

---------

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

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

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

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

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

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

Fixes #15070

## Type of change

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

## How Has This Been Tested?

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

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

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

## Checklist:

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

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

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

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

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

## Type of change

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

## How Has This Been Tested?

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

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

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

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


Related: https://github.com/chatwoot/chatwoot/pull/15291
2026-08-03 15:36:25 +05:30
Sivin Varghese
bc2cebd255 fix: encode contact search params so emails with a plus sign match (#15295) 2026-08-03 15:09:59 +05:30
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
Petterson
b2716e15e1 fix: deregister WhatsApp Cloud number on inbox delete (#14940)
## Description

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

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

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

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

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

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

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

---------

Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
2026-07-27 14:36:33 +05:30
Tanmay Deep Sharma
5e82e971fa fix(whatsapp): surface Meta's real error when an outbound call fails (#15178)
When an outbound WhatsApp call could not be placed, agents sometimes saw
a completely empty error (`{"error":""}`) with no indication of what
went wrong. This makes an outbound call that is blocked at Meta (for
example, a WhatsApp Business Account with a billing/eligibility problem)
look like a generic, unexplained failure. Agents now see the actual
reason Meta returned.

## Closes

No linked issue — found via a customer support investigation (outbound
calling returning `422 {"error":""}`).

## How to reproduce

1. On an account with WhatsApp calling enabled, place an outbound call
to a contact whose WhatsApp Business Account is not eligible for calling
(Meta returns error code `131044`, "Business eligibility payment issue
for calling").
2. Before: the call fails with `422 {"error":""}` — an empty message.
3. After: the call fails with Meta's actual message (e.g. "Business
eligibility payment issue for calling"), so the agent/admin knows it is
a Meta-side eligibility issue to resolve, not a Chatwoot bug.

## What changed

Meta's error responses can contain an **empty** `error_user_msg` (`""`)
while the real reason lives in `error.message` / `error_user_title` —
notably error `131044`. The previous code used `parsed.dig('error',
'error_user_msg') || 'Failed to initiate call'`, but an empty string is
truthy in Ruby, so the blank message was surfaced instead of the
fallback.

- Added a small `meta_error_message(parsed, default)` helper that
prefers the first **non-blank** field: `error_user_msg` → `message` →
`error_user_title` → default.
- Used it in both `process_initiate_call_response` (outbound call
initiate) and `update_calling_status`, which had the same pattern.

The `[WHATSAPP CALL] initiate_call failed: status=… body=…` server log
(with the full Meta body) is unchanged and remains the source of truth
for debugging.

---------

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

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

## Closes

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

## Type of change

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

## What changed

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

## How to test

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

## Checklist

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

---------

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

## How to test

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

## What changed

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

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

---------

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

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

## Closes


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

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

## Type of change

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

## How Has This Been Tested?

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

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-24 14:00:01 +05:30
Sony Mathew
6baf442c28 fix: enforce email limits for agent invitations (#15082)
# Pull Request Template

## Description

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

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

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

## Type of change

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

## How Has This Been Tested?

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

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-24 13:59:28 +05:30
Muhsin Keloth
d644c207f0 feat: monitor whatsapp phone number health (#15100)
Adds scheduled WhatsApp Cloud API phone-number health synchronization
and expands the Account Health page with phone, capacity,
business-account, webhook, coexistence, and recovery details.
Authorization failures now guide administrators to the appropriate
Configuration flow without exposing technical Meta error codes.

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

**Preview**

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

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


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



### How to test

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

### What changed

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

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

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-24 12:00:37 +04:00
Sony Mathew
1bff2d86ed fix: stabilize Intercom client error spec (#15154)
Stabilizes the Intercom transport-error spec when Rails reloads
application constants during the full backend suite. The production
client behavior is unchanged; the expectation now compares the exception
class name while continuing to verify its message and metadata.

## Closes

- Follow-up to
[CW-7615](https://linear.app/chatwoot/issue/CW-7615/optimize-intercom-import-reliability-and-bulk-message-ingestion)
- Follow-up to #15050

## How to reproduce

1. Run the backend shard containing the Intercom client spec after specs
that trigger Rails constant reloading.
2. Observe that the old matcher can reject an exception whose printed
class is still `DataImports::Intercom::Client::Error` because it
references a different class object.
3. Confirm the updated expectation accepts the reloaded class by name
and still verifies the transport error message and body.

## What changed

- Compare the raised Intercom client error using `error.class.name`.
- Preserve assertions for the user-facing error message and transport
error metadata.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] New and existing focused unit tests pass locally with my changes
2026-07-24 13:12:27 +05:30
Aakash Bakhle
7910eabefc feat(captain): add FAQ suggestion review interface (4/4) (#15017)
Captain now groups recurring questions from resolved conversations into
FAQ suggestions and orders them by the number of source conversations.
Agents can view suggestions and open source conversations they can
access. Administrators can edit, approve, or dismiss suggestions.

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

## Depends on

#14979

## Closes

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

## How to test

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

## What changed

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-24 12:53:09 +05:30
Sony Mathew
7786e38df9 feat: retry stalled Intercom imports after 15 minutes (#15050)
## Description

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

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

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

## Closes

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

## Type of change

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

## How to test

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

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have added tests that prove the change is effective
- [x] New and existing focused tests pass locally with my changes
2026-07-24 12:30:36 +05:30
Aakash Bakhle
7948ea09ac feat(captain): add FAQ suggestion review API (3/4) (#14979)
Agents can review recurring FAQ suggestions when they can access at
least one supporting conversation. The detail view returns only source
conversations the agent can access. Administrators can review every
suggestion and can edit, approve, or dismiss it. Approval creates one
approved Captain FAQ and removes the stored source observations.

This is the third PR in the CW-7495 stack. It is built on
[#14978](https://github.com/chatwoot/chatwoot/pull/14978), which adds
the FAQ suggestion models and generation flow.

## Closes

Closes
[CW-7495](https://linear.app/chatwoot/issue/CW-7495/backend-llm-changes-to-make-conversation-faqs-as-signalssuggestions).

## What changed

1. Added a paginated suggestion list with assistant, status, and search
filters.
2. Limited agents to suggestions that have at least one source
conversation they can access.
3. Limited the detail response to the 50 most recent source
conversations the current user can access.
4. Allowed administrators to edit, approve, and dismiss open
suggestions.
5. Added approval that creates one approved Captain FAQ, closes the
suggestion, and removes its source observations.
6. Rejected approval when the suggestion language does not match the
account language.
7. Added row locking so an edit or dismissal cannot overwrite an
approval.
8. Prevented FAQ generation from attaching a new observation after a
suggestion has closed.

## How to test

1. Sign in as an agent who has access to one inbox but not another.
2. Confirm the agent sees only suggestions with at least one source
conversation from an accessible inbox.
3. Open a suggestion and confirm the source list does not contain
conversations from restricted inboxes.
4. Sign in as an administrator and confirm all account suggestions are
available.
5. Edit an open suggestion and approve it. Confirm one approved FAQ is
created and the suggestion no longer has source observations.
6. Try to approve a suggestion in a different language from the account
language. Confirm the request is rejected.
7. Dismiss another open suggestion and confirm it leaves the open review
queue.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-24 12:12:13 +05:30
Aakash Bakhle
e10b236871 feat(captain): group conversation FAQ signals (2/4) (#14978)
Captain can now turn resolved conversations into FAQ suggestions that
people can review. When a human support agent gives a reusable answer,
Captain saves the question and answer as an observation. Captain groups
matching observations into one suggestion instead of creating a pending
FAQ for every conversation. This PR does not approve or publish FAQs.

PR [#14977](https://github.com/chatwoot/chatwoot/pull/14977) adds the
data model and should be reviewed first. The controller and UI PRs will
add the review flow. The three PRs should merge together.

## Closes


[CW-7495](https://linear.app/chatwoot/issue/CW-7495/backend-llm-changes-to-make-conversation-faqs-as-signalssuggestions).
This is PR 2 of 3. The issue is complete after the full stack lands.

## What changed

1. Runs FAQ generation in the low priority queue after a conversation is
resolved.
2. Reads only customer messages and answers written by human support
agents.
3. Uses the assistant's product details, instructions, response rules,
and guardrails to reject spam and unrelated conversations.
4. Stores each reusable question and answer as an observation.
5. Uses exact text similarity search within the conversation language to
find likely matches.
6. Asks the LLM whether both FAQs ask the same question and give the
same answer.
7. Does not create a new suggestion when an approved FAQ already covers
the observation.
8. Adds matching observations to an existing open suggestion and updates
its source count.
9. Does not suggest the same FAQ again after someone dismisses it.
10. Creates a new open suggestion only when no approved FAQ or existing
suggestion covers the observation.

## How to test

1. Resolve a conversation where a human support agent gives a reusable
answer. Confirm that Captain creates one open suggestion with one
source.
2. Resolve another conversation with the same question and answer.
Confirm that Captain adds a source to the existing suggestion instead of
creating another suggestion.
3. Resolve a conversation that is already covered by an approved FAQ.
Confirm that Captain creates no new suggestion.
4. Dismiss a suggestion, then resolve another conversation with the same
question and answer. Confirm that Captain does not suggest the FAQ
again.
5. Resolve a spam or unrelated conversation. Confirm that Captain
creates no suggestion.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-24 11:33:47 +05:30
Sojan Jose
56e72eff8d feat: assign connected agent bot owner (#14875)
Connected Agent Bot-handled conversation creation now records the bot as
the explicit owner instead of relying on pending status alone.

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

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

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

## Validation
- Create a conversation in an inbox with an active connected Agent Bot
and verify it is pending and owned by the Agent Bot.
- Create a conversation in that inbox with an explicit human assignee
and verify the human assignee is preserved.
- Create a bot-initiated campaign conversation in the same inbox and
verify it is pending and owned by the Agent Bot.
- Create a human-sender campaign conversation and verify it remains open
without Agent Bot ownership.
- Create a Dialogflow-handled pending conversation and verify no Agent
Bot owner is set.
- Trigger bot handoff and verify the Agent Bot owner is cleared.
2026-07-23 19:22:35 -07:00
Ajith KV
2a1dd481e9 test(playwright): add agent onboarding and inbox creation UI tests (#14707)
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>
2026-07-23 23:24:03 +05:30
Sivin Varghese
a98666030b chore: Calls page UI improvements (#15129) 2026-07-23 18:45:56 +05:30
Nicky Duijf
bae20ca83e feat(conversation): add label search to right-click context menu (#15084) 2026-07-23 15:10:46 +05:30
Sony Mathew
954e5844a8 Merge branch 'release/4.16.1' into develop 2026-07-23 13:58:16 +05:30
Sony Mathew
0efab5fb43 Bump version to 4.16.1 2026-07-23 13:57:15 +05:30
Muhsin Keloth
34ad78b122 fix(instagram): remove resolved restriction banners (#15136) 2026-07-23 12:56:21 +05:30
Shivam Mishra
ddb0535a93 perf: reuse resolved count for reopen rate (#15122)
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.
2026-07-22 22:03:25 +05:30
Sivin Varghese
42cbf7d3b9 fix: stray backslash after hard breaks before formatted list items (#15112) 2026-07-22 20:07:00 +05:30
Sony Mathew
887897ea98 fix: lock agent quota checks (#15029)
# 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>
2026-07-22 18:43:00 +05:30
Tanmay Deep Sharma
8aee518149 fix(integrations): restrict Linear/Notion/Shopify hook deletion to admins (#15126)
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>
2026-07-22 17:53:25 +05:30
Vishnu Narayanan
5733b822e3 fix: guard widget email-transcript button against repeat clicks (#15095)
## 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
2026-07-22 17:36:36 +05:30
Sivin Varghese
1e52d23d7a fix: guard agent sort against null names in assignment dropdown (#15125) 2026-07-22 15:27:04 +05:30
Sivin Varghese
fbb3479263 fix: guard agent sort against null names in assignment dropdown (#15125)
# 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
2026-07-22 15:23:19 +05:30
Muhsin Keloth
166a41c31c fix(whatsapp): prevent invalid automation sends outside reply window (#15113)
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>
2026-07-22 12:40:28 +04:00
Muhsin Keloth
e65e18e9c5 fix: normalize phone numbers during whatsapp channel lookup (#13709)
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>
2026-07-22 10:33:40 +04:00
Shivam Mishra
89b83c65c8 fix: close message generation popover when its trigger scrolls away (#15114) 2026-07-21 19:34:57 +05:30
Tanmay Deep Sharma
ed30ff9c22 fix(whatsapp): allow calling a contact with no existing conversation (#15014)
## 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>
2026-07-21 18:08:32 +05:30
Muhsin Keloth
7d2f01e402 feat(whatsapp): unify embedded signup feature gating (#15106)
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>
2026-07-21 15:05:11 +04:00
Shivam Mishra
8c013415b8 fix: localize the captain overview summary greeting (#15108) 2026-07-21 16:29:08 +05:30
Tanmay Deep Sharma
2144de92f2 fix(whatsapp): reopen conversation across a contact's coexistence identities (#15098)
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.
2026-07-21 16:21:40 +05:30
Tanmay Deep Sharma
0e376f4fe2 feat(whatsapp-call): support BSUID callers for inbound voice calls (#14743)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-7276/bsuid-support-to-whatsapp-voice-calling

## Description

Keeps WhatsApp voice calls in the same thread as the chat when a caller
has adopted a **WhatsApp username** and hidden their phone number.
This makes the inbound-call path BSUID-aware, reusing the same
identifier the messaging pipeline keys on so calls land on the existing
`ContactInbox`/conversation.

## Type of change

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

## How Has This Been Tested?

-  Locally 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>
2026-07-21 16:19:53 +05:30
Shivam Mishra
67cab7171d feat: show Captain generation path on conversation messages [CW-7484] (#15078) 2026-07-21 15:15:13 +05:30
Shivam Mishra
7a5385cc32 feat: improve captain overview loading and reuse stats for summary [CW-7610] (#15105) 2026-07-21 15:14:18 +05:30
Sivin Varghese
920a98ccf4 fix: calls dashboard load race (#15094) 2026-07-21 15:00:08 +05:30
Aakash Bakhle
ae49af354d fix: serialize multimodal Captain session content (#15096)
Captain now saves agent session records when a user message includes an
image. The saved record keeps the image URL and excludes downloaded
image bytes, so image replies no longer report a JSON serialization
error after delivery.

Fixes:
https://chatwoot-p3.sentry.io/issues/7618423184/?alert_rule_id=13673680&alert_type=issue&notification_uuid=d22a7ab9-95d6-4bba-85e0-733a28466775&project=6382945

## Root cause

RubyLLM downloads image attachments and caches the binary bytes inside
`RubyLLM::Content`. `SessionCaptureService` passed the live object to
the `run_context` JSON column. Rails then tried to encode the cached
JPEG bytes as UTF-8 and raised `JSON::GeneratorError`.

The error did not block replies, handoffs, or credit updates because
session capture rescues its own failures. The failed write meant that
Chatwoot lost the agent session record for the response.

## How to reproduce

1. Send an image to a Captain V2 assistant.
2. Let RubyLLM load the image during the model request.
3. Save the resulting conversation history in an agent session.
4. Observe the JSON encoding error when Rails reaches the cached image
bytes.

## What changed

`SessionCaptureService` now converts `RubyLLM::Content` to its JSON safe
hash before saving the current turn. The hash contains the message text
and attachment URL without the cached bytes. Other message content is
unchanged.

The focused service spec covers a cached JPEG byte payload and passes
with 12 examples. RuboCop reports no offenses in the changed service and
spec.
2026-07-21 13:04:12 +05:30
Devi R
d1fa8d8c2f refactor: share whatsapp/twilio template logic via @chatwoot/utils (#15001)
# 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>
2026-07-21 10:08:23 +04:00
Vishnu Narayanan
71fffdd2b9 fix: rate limit widget conversation transcript API (#15085)
## Description

The widget conversation transcript endpoint (`POST
/api/v1/widget/conversations/transcript`) has no rate limit. Every other
comparable endpoint does: the agent-facing transcript API and the widget
conversation-create and contact-update endpoints are all throttled. This
gap lets a single client trigger a large burst of transcript emails from
one conversation.

This adds an IP-based throttle (5 requests/hour) for the endpoint,
placed inside the existing widget-API throttle block so it inherits the
`ENABLE_RACK_ATTACK_WIDGET_API` opt-out used by embedded/iframe clients.
The limit is generous for legitimate use (a visitor emailing themselves
a transcript) while stopping abusive loops. Throttled requests get the
standard 429 the widget already handles.

## Type of change

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

## How Has This Been Tested?

`config/initializers/rack_attack.rb` throttles have no existing specs in
this file, so this follows the established convention (no new spec).
Verified `ruby -c` and `rubocop` pass on the file. The new throttle
mirrors the sibling widget throttles directly above it (same IP key,
path guard, and structure).

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

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-20 16:09:29 -07:00
Sojan Jose
eae9841eb4 fix: restore token access to account APIs (#15088)
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.
2026-07-20 15:28:33 -07:00
Sony Mathew
160732c07d fix: rate limit agent management APIs (#15081)
# Pull Request Template


Bring  agent create and delete requests under rack attack throttling

Related to https://linear.app/chatwoot/issue/CW-7637


Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-07-20 20:41:39 +05:30
Sivin Varghese
7a299307b8 fix: apply installation name to sender name preview (#15076) 2026-07-20 19:28:58 +05:30
Sivin Varghese
08f49f5896 fix: prevent channel list crash on hard reload (#15074) 2026-07-20 19:28:48 +05:30
Sivin Varghese
bf0a10c780 feat: introduce voice call dashboard (#14954) 2026-07-20 14:54:05 +05:30
Sojan Jose
a752e56765 Merge branch 'release/4.16.0' into develop 2026-07-18 03:59:29 -07:00
Sojan Jose
ff6c068743 Bump version to 4.16.0 2026-07-18 03:54:55 -07:00
Sojan Jose
465763f256 feat: enable Uzbek language (#15056)
Enables Uzbek as a selectable Chatwoot language and wires the `uz`
locale into the dashboard, widget, and survey translation loaders.

## Closes

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

## Why

Uzbek is now available in the Chatwoot Crowdin project. The widget
translation has been proofread and is 100% translated and approved.

## What changed

- Adds Uzbek to the supported language registry
- Registers the `uz` locale with the dashboard, widget, and survey i18n
loaders

## Validation

- Proofread the Uzbek widget translation in Crowdin
- Verified placeholder preservation and corrected wording where needed
- Confirmed the widget file is 100% translated and 100% approved

This draft intentionally remains blocked until the Crowdin sync adds the
generated Uzbek locale files. It should only be marked ready and merged
after that sync lands.
2026-07-18 03:40:53 -07:00
Captain
e70bbfaaa5 chore: Update translations (#15059)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-18 02:59:24 -07:00
Sojan Jose
a61a8bfbc6 chore(deps): address bundle audit vulnerabilities (#15055)
Updates the vulnerable Ruby dependencies reported by bundle audit so the
application resolves to patched versions.

## Why

The latest Ruby advisory database reports known vulnerabilities in
Datadog, Loofah, and Rails HTML Sanitizer on `develop`, including a
high-severity denial-of-service advisory in Datadog.

## What changed

- Updates `datadog` from 2.19.0 to 2.38.0
- Updates `loofah` from 2.23.1 to 2.25.2
- Updates `rails-html-sanitizer` from 1.6.1 to 1.7.1
- Refreshes Datadog's required native packages

## Validation

Verified the resolved bundle is satisfied, bundle audit reports no known
vulnerabilities, and Datadog 2.38.0 loads successfully.
2026-07-17 16:04:56 -07:00
Pranav
4cb89d0de1 chore: Update brand assets (#15054)
Refresh favicons and app icons from the official brand kit and align the
PWA theme colors with the current brand blue.
2026-07-17 13:56:39 -07:00
Sony Mathew
259187e1fd docs: add branded email layout API reference (#15023)
## Description

Adds Swagger API documentation for branded email layouts, including the
account-level layout endpoint, Email inbox update field, and generated
API schemas. This stacked PR keeps the implementation review in #14936
focused on the product change.

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

## Type of change

- [x] This change requires a documentation update

## How to test

1. Run `bundle exec rake swagger:build`.
2. Start Chatwoot locally and open `http://localhost:3000/swagger`.
3. Verify the branded email layout account endpoint and Email inbox
`branded_email_layout` field appear in the API reference.

## Checklist

- [x] I have performed a self-review of my changes
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
2026-07-17 16:20:00 +05:30
Muhsin Keloth
90861f8809 feat(whatsapp): gate embedded signup inbox creation (#15046)
WhatsApp inbox creation now shows Embedded Signup for Chatwoot Cloud
accounts only when the new `whatsapp_embedded_signup_inbox_creation`
feature flag is enabled. Cloud accounts without the flag go directly to
manual WhatsApp Cloud API setup, while self-hosted installations with a
configured WhatsApp App ID retain their existing Embedded Signup flow.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-17 14:09:36 +04:00
Sony Mathew
c420edce58 feat: branded email layouts for email inboxes (#14936)
## Description

Adds an API-only branded email layout feature for Email inbox replies.
Administrators can configure an account-level fallback layout and
per-email-inbox overrides with Liquid HTML using `{{ content_for_layout
}}`, and eligible outbound email replies/transcripts render through the
scoped layout when the account feature flag `branded_email_templates` is
enabled.

The feature is disabled by default and is manually controlled through
the normal account feature flag mechanism.

Fixes
https://linear.app/chatwoot/issue/CW-7514/branded-html-email-templates-per-inboxbrand

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

## How to test

1. Start Chatwoot locally and sign in as an administrator.
2. Enable the account feature flag for the account you are testing:

   ```ruby
   account = Account.find(<account_id>)
   account.enable_features!(:branded_email_templates)
   ```

3. Create or pick an Email inbox, then note the `account_id` and
`inbox_id`.
4. Configure an account-level fallback layout through the API using
authenticated admin headers:

   ```http
   PATCH /api/v1/accounts/:account_id/branded_email_layout
   Content-Type: application/json

   {
"branded_email_layout": "<html><body><header>Account Brand</header>{{
content_for_layout }}<footer>Account footer</footer></body></html>"
   }
   ```

5. Confirm `GET /api/v1/accounts/:account_id/branded_email_layout`
returns the saved account layout.
6. Configure an inbox-level override for the Email inbox:

   ```http
   PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id
   Content-Type: application/json

   {
"branded_email_layout": "<html><body><header>Inbox Brand</header>{{
content_for_layout }}<footer>Inbox footer</footer></body></html>"
   }
   ```

7. Confirm `GET /api/v1/accounts/:account_id/inboxes/:inbox_id` returns
the inbox `branded_email_layout`.
8. Send an Email inbox reply and verify the outbound email body is
wrapped with the inbox layout around the generated reply content.
9. Clear the inbox layout by sending a blank value, then send another
reply and verify it falls back to the account layout:

   ```http
   PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id
   Content-Type: application/json

   {
     "branded_email_layout": ""
   }
   ```

10. Clear the account layout with a blank value and verify Email replies
return to the existing no-layout behavior.
11. Verify validation behavior:
- Updating either API with a layout that omits `{{ content_for_layout
}}` returns `422`.
    - Updating either API with invalid Liquid returns `422`.
- Updating a non-Email inbox with `branded_email_layout` returns `422`.
- Disabling `branded_email_templates` and updating a layout returns
`422`.

## How Has This Been Tested?

Validation:

- `bundle exec rspec spec/models/email_template_spec.rb
spec/controllers/api/v1/accounts/branded_email_layouts_controller_spec.rb
spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
spec/lib/email_templates/db_resolver_service_spec.rb
spec/mailers/conversation_reply_mailer_spec.rb`
- `bundle exec rspec
spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb`
- `bundle exec rubocop` on changed Ruby files, excluding generated
`db/schema.rb`
- `git diff --check` and `git diff --cached --check`
- YAML parsing for changed config/Swagger files
- `bundle exec rails routes -g branded_email_layout`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have made corresponding changes to the documentation
- [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
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] My changes generate no new warnings
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-17 15:25:55 +05:30
Sivin Varghese
331875cdaa feat: Add popular content per locale (#14939) 2026-07-17 14:43:42 +05:30
Sivin Varghese
5af26e45a9 chore: Cleanup help center layouts (#14812) 2026-07-17 14:07:52 +05:30
Muhsin Keloth
f948b0b8d9 test(onboarding): fix inbox channel dialog spec (#15048)
Restores the Inbox Channels dialog’s Facebook-gating test coverage by
isolating account feature dependencies that are unrelated to these
cases. This keeps the onboarding checks focused on whether Facebook is
configured and avoids initializing router and account-store state.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-17 13:57:34 +05:30
Muhsin Keloth
081e08c7c6 fix(whatsapp): skip template sync for suspended accounts (#15047)
Suspended accounts no longer participate in scheduled WhatsApp template
syncs. This avoids unnecessary external API calls while keeping the
existing refresh behavior unchanged for active accounts.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-17 11:57:07 +04:00
Sivin Varghese
dc3a4814aa fix: resolve {{conversation.id}} to display_id (#15041) 2026-07-17 12:55:48 +05:30
Muhsin Keloth
5e811eab99 chore(inbox): re-enable Instagram inbox creation (#15042)
Instagram inbox creation is available again on Chatwoot Cloud. Users can
discover and connect Instagram during onboarding or from Add Inbox,
while WhatsApp restrictions and existing-inbox Instagram advisories
remain unchanged.

Closes https://linear.app/chatwoot/issue/CW-7549/enable-instagram

## How to test

1. On Chatwoot Cloud, open Add Inbox and confirm Instagram can be
selected.
2. Confirm **Continue with Instagram** is enabled and starts the OAuth
flow.
3. In onboarding, confirm Instagram is displayed and can start OAuth.
4. Confirm WhatsApp embedded signup remains restricted.

## What changed

- Removed the Cloud-only Instagram filter and OAuth guard from
onboarding.
- Re-enabled the regular Instagram inbox creation action on Cloud.
- Removed the obsolete “Instagram inbox creation is temporarily
unavailable” copy.
- Updated the onboarding expectation for Chatwoot Cloud.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-16 20:28:49 +04:00
Shivam Mishra
9749a3dc96 feat: capture captain sessions for v2 assistant responses [CW-7485] (#14971)
Records a `Captain::Session` row for every Captain V2 assistant response
delivered in a conversation, so we can show how a response was generated
and report on credit, FAQ, and document usage. Stacked on #14970 (the
`captain_sessions` model).

## What changed

- `FaqLookupTool` now records the retrieved FAQ ids (and their backing
document ids) into the shared run state, accumulated across tool calls.
- `AgentRunnerService` exposes the raw ai-agents run result via
`last_run_result`; the `generate_response` return shape is unchanged, so
the playground path is unaffected.
- New `Captain::Assistant::SessionCaptureService` builds the session:
scenario resolved from the answering agent name, model from
`assistant.agent_model`, token usage plus the trimmed current-turn
conversation history stored in `run_context`.
- `ResponseBuilderJob` captures after delivery: `credits_consumed`
mirrors the actual charge (1.0 for a billed response, 0.0 for handoffs,
where the session points at the customer-facing handoff message).
Capture runs outside the delivery transaction and swallows its own
failures, so a logging bug can never block or roll back a customer
reply.

V1 responses and copilot are out of scope; copilot capture comes next.

## How to test

On an account with `captain_integration_v2` enabled and an inbox
connected to an assistant with approved FAQs, send a customer message on
a pending conversation. After the assistant replies, a
`Captain::Session` row should exist with the conversation as subject,
the reply message as result, the FAQs/documents used, and the run
context for that turn. Asking for a human agent should produce a
zero-credit session pointing at the handoff message.

<img width="2428" height="1058" alt="CleanShot 2026-07-15 at 17 25
40@2x"
src="https://github.com/user-attachments/assets/d8e44923-c17b-494f-8c33-c8fa4219438c"
/>
2026-07-16 18:20:44 +05:30
Shivam Mishra
8dd0d08322 refactor: align conversation direct uploads with standard account auth (#15039)
Conversation attachment uploads now go through the same authentication
that every other account-scoped API endpoint uses. Agents continue to
attach files exactly as before, and the upload request is now tied to
the agent's dashboard session instead of a separately serialized access
token.

Because the upload request is now authenticated, the dashboard proves
the agent's session directly instead of passing
`currentUser.access_token`. This keeps uploads working alongside the
profile access-token changes in #14973, including on accounts where that
token is serialized as empty.

## What changed

- `Api::V1::Accounts::Conversations::DirectUploadsController` now runs
the standard account auth stack: API access token when the
`api_access_token` header is present, dashboard session
(devise-token-auth) otherwise, with agent-bot tokens rejected.
Previously it inherited `ActiveStorage::DirectUploadsController`
directly and did not run any authentication.
- `EnsureCurrentAccountHelper#ensure_current_account` now returns `401`
when a request has neither an authenticated user nor a bot resource,
instead of continuing. This closes the same gap for any controller that
relies on the helper.
- The dashboard direct-upload paths (`useFileUpload.js` and the legacy
`fileUploadMixin.js`) now attach the agent's session headers to the
upload request via a new `directUploadsHelper.js`, instead of sending
`currentUser.access_token`.

## How to test

1. As a logged-in agent, open a conversation and attach a file. Upload
should succeed as before, on installs with direct uploads enabled.
2. Confirm attachments still work for an agent on an account whose
profile access token is not serialized (e.g. a Cloud plan without
`api_and_webhooks`).
3. Send a `POST` to
`/api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads`
with no credentials, an empty `api_access_token`, or an invalid token,
and confirm it returns `401`.
4. Confirm a valid agent of the account (via API token or session) gets
`200`, while an agent of a different account gets `401`.
2026-07-16 18:17:29 +05:30
Muhsin Keloth
7e88d44fd9 fix(whatsapp): update manual migration guide link (#15040)
Updates the WhatsApp manual migration guide links in the inbox banner
and migration dialog to use `https://chwt.app/migrate-whatsapp`.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-16 15:57:37 +04:00
Tanmay Deep Sharma
2891a72cb9 feat(whatsapp): enable reconfigure for embedded signup inboxes (#15038) 2026-07-16 16:18:58 +05:30
Shivam Mishra
522e3c4d3f feat: enforce api_and_webhooks feature for token API and account webhooks (#14973)
This gates API-token access and outgoing account webhooks behind the
`api_and_webhooks` account feature introduced in #14972. On Chatwoot
Cloud, Hacker accounts lose token-authenticated account API access and
account webhook delivery, while paid accounts retain them through the
billing-plan feature reconcile. Community and self-hosted installations
continue to work without any upgrade-time interruption.

## What changed

- Added `Account#api_and_webhooks_enabled?` as the single backend kill
switch. Core returns enabled; the Enterprise override consults the
account flag on Chatwoot Cloud and remains enabled off-Cloud.
- Account-scoped v1 and v2 requests authenticated with a user or
agent-bot API token now return `403 Forbidden` when the feature is
disabled. Invalid tokens still return 401, and dashboard session
requests are unaffected.
- Profile responses return an empty access token when none of the user's
accounts has access. The stored token is preserved, and the profile UI
disables its token controls with paid-plan copy on Cloud.
- Account webhook delivery stops when the feature is disabled. Webhook
CRUD remains available to session-authenticated dashboard requests,
API-inbox webhooks continue to be delivered, and the Cloud dashboard
shows a webhook paywall instead of the webhook list.
- Removed the database backfill migration. Existing paid Cloud accounts
should be enabled with the one-off script below before enforcement is
deployed.

## Existing paid-account rollout

Run this as an ad-hoc Rails runner script on Chatwoot Cloud. It
intentionally targets only the Startups, Business, and Enterprise plans
and does not add `api_and_webhooks` to `manually_managed_features`, so
future billing reconciles remain authoritative.

```rb
paid_plan_names = %w[Startups Business Enterprise]
accounts = Account.where("custom_attributes ->> 'plan_name' IN (?)", paid_plan_names)

total = accounts.count
enabled = 0
skipped = 0

puts "Enabling api_and_webhooks for #{total} paid account(s)..."

accounts.find_each(batch_size: 500).with_index(1) do |account, processed|
  if account.feature_enabled?('api_and_webhooks')
    skipped += 1
  else
    account.enable_features!('api_and_webhooks')
    enabled += 1
  end

  puts "Processed #{processed}/#{total}..." if (processed % 1000).zero?
end

puts "Done! Enabled: #{enabled}, Skipped: #{skipped}, Total: #{total}"
```

For example, save the snippet outside the repository as
`enable_api_and_webhooks.rb`, then run:

```sh
bundle exec rails runner /path/to/enable_api_and_webhooks.rb
```

## How to test

- On Cloud, use a Hacker account and confirm token-authenticated
requests to account-scoped v1 and v2 endpoints return 403, while the
same dashboard actions continue to work through session authentication.
- Confirm profile access-token controls are disabled with paid-plan copy
when all accounts are ineligible, and remain available when at least one
account has the feature.
- Confirm the Webhooks settings page shows the billing paywall for a
Cloud account without the feature; admins get the billing action and
agents get the existing ask-an-admin message.
- Confirm outgoing account webhooks stop for an ineligible Cloud account
while API-inbox webhooks still deliver.
- Confirm community and self-hosted installations retain API and webhook
behavior after upgrading, even when an existing account does not have
the stored feature bit.


### Screenshots

## Cloud

<img width="2590" height="642" alt="CleanShot 2026-07-15 at 15 13 14@2x"
src="https://github.com/user-attachments/assets/431a7bd8-1742-4e7a-b312-d3ad92015f9b"
/>

<img width="2152" height="994" alt="CleanShot 2026-07-15 at 15 14 37@2x"
src="https://github.com/user-attachments/assets/475dda48-d1c5-4be5-a3c3-7a96b9713724"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-16 13:43:49 +04:00
Muhsin Keloth
0a25a0ef66 fix(whatsapp): log manual transfer only for embedded signup migrations (#15032)
The `[WHATSAPP_MANUAL_TRANSFER] success` log introduced in #14975 to
track embedded signup → manual migrations was firing on any WhatsApp
credential change — including routine `api_key` rotations on inboxes
that were already manually configured — inflating the migration count.
The log now fires only for the actual migration, and uses a new tag so
log searches don't match the older over-counted entries.

## How to reproduce

1. On a manually configured WhatsApp Cloud inbox, update the API key
from inbox settings → Configuration.
2. Before this change, the app log records a `[WHATSAPP_MANUAL_TRANSFER]
success` line even though no migration happened; after this change it
stays silent.
3. Switching an embedded signup inbox to manual setup still logs the
migration (now as `[WHATSAPP_EMBEDDED_TO_MANUAL] success`).

## What changed

- `Channel::Whatsapp#log_credentials_transfer` now keys off the
migration's unique signal — `provider_config['source']` changing from
`embedded_signup` to absent — instead of diffing credential keys.
- Renamed the log tag from `WHATSAPP_MANUAL_TRANSFER` to
`WHATSAPP_EMBEDDED_TO_MANUAL` (success and failure lines) so the
corrected entries are searchable without matching pre-fix false
positives.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-16 13:35:20 +04:00
Sivin Varghese
0fccb7dacd feat: stage edits to published articles as drafts (#14842) 2026-07-16 12:44:10 +05:30
Pranav
f1a07657e9 fix(whatsapp): restore embedded signup reauthorization banner (#15035)
Show the reauthorization banner whenever an embedded signup inbox has a
reauthorization error instead of suppressing it when the manual
migration flow is enabled. Reword the manual migration banner and dialog
as a recommendation to reconnect with your own Meta app, and switch
their styling from amber warning to blue info.
2026-07-16 12:28:30 +05:30
Sivin Varghese
9cb6c501b5 fix: html/body background not applied in appearance mode (#13989)
# Pull Request Template

## Description

This PR fixes the white background bleed visible in the widget, article
viewer, and Help Center when dark mode is active.
This change was previously merged but later reverted due to a background
being added for the unread bubble.

Reverted PR: https://github.com/chatwoot/chatwoot/pull/13955,
https://github.com/chatwoot/chatwoot/pull/13981

**What was happening**

While scrolling, the `<body>` element retained a white background in
dark mode. This occurred because dark mode classes were only applied to
inner container elements, not the root.

**What changed**

* **Widget:** Updated the `useDarkMode` composable to sync the `dark`
class to `<html>` using `watchEffect`, allowing `<body>` to inherit dark
theme variables. Also added background styles to `html`, `body`, and
`#app` in `woot.scss`.
* **Help center portal:** Moved `bg-white dark:bg-slate-900` from
`<main>` to `<body>` in the portal layout so the entire page background
responds correctly to dark mode, including within the widget iframe.
* **ArticleViewer:** Replaced hardcoded `bg-white` with `bg-n-solid-1`
to ensure better theming.


Fixes
https://linear.app/chatwoot/issue/CW-6704/widget-body-colour-not-implemented

## Type of change

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

## How Has This Been Tested?

### Screencasts

### Before

**Widget**


https://github.com/user-attachments/assets/e0224ad1-81a6-440a-a824-e115fb806728

**Help center**


https://github.com/user-attachments/assets/40a8ded5-5360-474d-9ec5-fd23e037c845



### After

**Widget**


https://github.com/user-attachments/assets/dd37cc68-99fc-4d60-b2ae-cf41f9d4d38c

<img width="347" height="265" alt="image"
src="https://github.com/user-attachments/assets/89460b36-c8ed-4579-9737-c258c6fe5da5"
/>


**Help center**


https://github.com/user-attachments/assets/bc998c4e-ef77-46fa-ac7f-4ea16d912ce3




## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-16 12:21:01 +05:30
Vishnu Narayanan
ab01ab7853 fix(perf): back off conversation meta polling when the count is unknown (#15031)
## Description

The conversation sidebar counts (`GET /conversations/meta`) are polled
on a debounced cadence selected from the last known conversation count
(`allCount` in the `conversationStats` store).

`allCount` starts at `0` and is only set after a **successful** meta
response. When the meta query is slow or failing under load, `allCount`
stays `0`, which selected the fastest cadence, so every client polled as
aggressively as possible exactly when the endpoint was already
struggling. On large accounts with many concurrent agents this
multiplies into sustained load that keeps the query slow, a
self-reinforcing loop.

This PR makes two changes:

1. **Treat an unknown count (`0`) as a large account** and poll at the
slowest cadence instead of the fastest (`getMetaDebounceKey`, extracted
as a pure, testable function). This breaks the failure loop.
2. **Raise the debounce intervals across all tiers** so counts are
polled less frequently under sustained activity:
   - fast: max 1 poll / 2s -> 1 / 5s
   - mid: 1 / 10s -> 1 / 20s
   - slow (large/unknown accounts): 1 / 20s -> 1 / 30s

First-load counts still render immediately (the debounce fires on the
leading edge for the very first call), so the higher intervals only
affect the sustained poll rate, not initial render.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2026-07-16 11:41:28 +05:30
Aakash Bakhle
6d74ff9477 fix(captain): make assistant switcher scrollable (#15027) 2026-07-16 10:44:22 +05:30
Aakash Bakhle
354c2cab6b fix(captain): handle resolved conversation context (#14433)
# Pull Request Template

## Description

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

Uses approaches discussed from:
https://github.com/chatwoot/chatwoot/pull/13883

Activity messages pertaining to resolve are included along with an
instruction for the LLM to choose whether to consider them or not along

## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and with specs


## 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-15 23:30:17 +05:30
Aakash Bakhle
9c68eed676 fix(captain): send custom tool API key headers (#15008)
Captain custom tools configured with API key authentication now send the
configured key in the requested HTTP header. Existing tools begin
working without needing to be recreated or reconfigured, while
credentials remain protected across redirects.

## How to reproduce

1. Create a Captain custom tool using API Key authentication.
2. Configure `X-API-Key` as the header name and save the tool.
3. Invoke the tool and inspect the incoming request.
4. Before this change, the API key header is absent; after this change,
the configured endpoint receives it.

## What changed

The UI persists API key authentication as `name` and `key`, but the
request builder also required an unused `location: header` property. The
request builder now treats API key authentication as header-based,
matching the only mode exposed by the UI.

Custom authentication headers are also registered as sensitive with
`SafeFetch`. They are retained for the configured endpoint and
same-origin redirects, but stripped when a redirect crosses origins to
prevent credential leakage. Factory, request, and redirect specs cover
the real UI payload and both public and private-network fetch paths.
2026-07-15 22:43:30 +05:30
Aakash Bakhle
6d38b4d39c fix(captain): improve complex migration instructions (#15002)
Improves Captain V1 → V2 migration for complex legacy instructions so
mandatory triggers, workflows, language rules, and escalation behavior
remain active while query-dependent product knowledge is prepared as
pending FAQ candidates.

## What changed

- Added explicit preservation rules for mandatory triggers, verification
steps, escalation conditions, exceptions, and language behavior.
- Added an auditor that checks the draft and fixes any issues before
manual review.
- Kept the existing migration application contract and schema limits
unchanged
- Added focused regression coverage for the complex-prompt classifier
contract.

## How to reproduce

Generate a migration draft for an assistant with dense legacy
instructions containing mandatory handoff triggers, verification rules,
product facts, and multi-step workflows. The resulting draft should keep
actions active, place query-dependent facts in FAQ candidates, and avoid
silently dropping or reversing source requirements.

Focused Captain migration specs and RuboCop checks pass locally.
2026-07-15 14:51:51 +05:30
Aakash Bakhle
fd625981e9 fix(captain): keep custom tools available in Captain V2 (#15015)
Captain V2 assistants can now use every enabled custom tool from their
account through the main assistant. The change keeps existing custom
tool access when an assistant has no migrated scenarios, so switching
from V1 does not remove the capability without warning.

## How to reproduce

1. Create and enable an account custom tool.
2. Use an assistant with no custom instructions and no generated
scenarios.
3. Enable Captain V2 for the account.
4. Before this change, the main assistant receives only FAQ lookup and
handoff. After this change, it also receives the enabled account custom
tool.

## What changed

The main V2 assistant now loads enabled custom tools through its account
association. Scenario agents still load only the tools named in their
scenario instructions. The account custom tool limit keeps the added
tool count bounded.

Focused model coverage verifies enabled tools, disabled tools, account
isolation, FAQ lookup, and handoff. Existing V1 assistant, V2 scenario,
and V2 runner coverage passes. RuboCop passes.
2026-07-15 11:35:57 +05:30
Gaurav Singhal
49c442751d fix: require admin for dashboard app mutations (#14831)
## Summary

Restricts account-wide Dashboard App creation, updates, and deletion to
administrators while keeping read access available to authenticated
account users.

## Why

Dashboard Apps are account-level integrations displayed in conversation
views. Agents should be able to use them, but only administrators should
be able to change their configuration.

## What changed

- authorize Dashboard App actions through `DashboardAppPolicy`
- allow index and show access for authenticated account users
- restrict create, update, and destroy actions to administrators
- add request coverage for administrator and agent mutation behavior

## Validation

`bundle exec rspec
spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb`

17 examples, 0 failures.

`bundle exec rubocop
app/controllers/api/v1/accounts/dashboard_apps_controller.rb
app/policies/dashboard_app_policy.rb`

2 files inspected, no offenses detected.



---------

Co-authored-by: Gaurav Singhal <gauravsinghal@Gauravs-Mac-mini.local>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-14 23:02:13 -07:00
Gaurav Singhal
13db36609d fix: hide agent bot access tokens from agents (#14830)
## Summary

Keeps Agent Bot list and show access available to agents while
restricting account bot access tokens to administrators.

## Why

Agents need Agent Bot metadata for existing product workflows, but the
bot access token can be replayed against bot-authorized APIs and should
not be exposed to them.

## What changed

- serialize `access_token` only for administrators
- verify agents can read Agent Bot metadata without receiving the token
- verify administrators still receive the token from index and show
responses

## Validation

`bundle exec rspec
spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb`

27 examples, 0 failures.

Related follow-up:
[CW-7595](https://linear.app/chatwoot/issue/CW-7595/standardize-one-time-credential-disclosure-across-chatwoot-apis)



---------

Co-authored-by: Gaurav Singhal <gauravsinghal@Gauravs-Mac-mini.local>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-14 20:00:39 -07:00
Muhsin Keloth
8b4f3e226e revert: "fix(meta): disable Instagram replies on Cloud during restriction" (#15020)
Reverts chatwoot/chatwoot#15005
2026-07-14 14:59:41 -07:00
Aakash Bakhle
2ac55c8728 fix(captain): honor mandatory handoff guidelines (#15003)
Ensures Captain follows explicit mandatory-transfer rules from active
Response Guidelines and Guardrails instead of allowing the generic
consent-first fallback to override those rules.

## What changed

- Made explicit transfer requirements take precedence over generic
consent-first handoff defaults only when their condition matches.
- Added explicit Response Guideline and Guardrail transfer rules to the
human-handoff protocol.
- Added focused prompt regression coverage.

## How to reproduce

Configure a Response Guideline or Guardrail that requires immediate
transfer for a specific condition, then send a request matching that
condition. Captain should invoke the human-handoff path without asking
the user to consent again. Unmatched requests continue to use the
existing consent-first fallback.

The assistant prompt renderer, agent prompt context, and focused
regression specs pass locally.
2026-07-14 19:38:43 +05:30
Tanmay Deep Sharma
9328f8739c fix: clear whatsapp webhook override when manual cloud inbox is deleted (#15010)
Deleting a manually-configured WhatsApp Cloud inbox left its
phone-number-level webhook override still pointing at Chatwoot on Meta's
side. The number kept routing inbound events to us after the inbox was
gone, which blocked the customer's own app — subscribed separately on
the same WABA — from receiving messages, since the phone-level override
takes priority over the app-level subscription. Deleting the inbox now
releases the override, as it already did for embedded-signup inboxes.

## What changed

The setup and teardown paths gated on opposite halves of the same
condition. `Channel::Whatsapp#should_auto_setup_webhooks?` sets the
override for `whatsapp_cloud` inboxes where `source !=
'embedded_signup'` (i.e. manual ones), while
`Whatsapp::WebhookTeardownService#should_teardown_webhook?` only cleared
it when `source == 'embedded_signup'`. The two sets are disjoint, so
manual inboxes were exactly the ones that set an override on create and
never cleared it on destroy. Embedded-signup inboxes were unaffected
because `EmbeddedSignupService` calls `setup_webhooks` explicitly.

Dropping the `source` check from the teardown guard is the whole fix.
Manual `whatsapp_cloud` channels can't persist without `api_key`,
`phone_number_id` and `business_account_id` (`validate_provider_config`
verifies all three against Meta), so the remaining presence guards and
both API calls have everything they need. The WABA-level `DELETE
/subscribed_apps` now also fires for manual inboxes when the last one on
a WABA is removed, which is symmetric with manual setup subscribing the
app in the first place; the token only unsubscribes the app it belongs
to, so a customer's separate app subscription is untouched.

This fixes the leak going forward. Numbers already stranded still need
the override cleared with the customer's own token, since we no longer
hold their `api_key` once the inbox is deleted.

## How to reproduce

1. Create a WhatsApp Cloud inbox using manual API keys (not embedded
signup).
2. Confirm the override is set: `GET
/v22.0/{phone_number_id}?fields=webhook_configuration` shows
`phone_number` pointing at your Chatwoot install.
3. Delete the inbox.
4. Before this change, the override still points at Chatwoot. After it,
`webhook_configuration` no longer carries the phone-level override and
events fall back to the WABA/app-level subscription.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-14 15:05:27 +05:30
Muhsin Keloth
3e03f8da1e chore(whatsapp): log warning when Cloud API template sync fails (#15004)
WhatsApp Cloud API template sync currently fails silently — if the Graph
API call errors (expired token, rate limit, permission issue), the
channel simply keeps its stale templates with no trace in the logs. This
adds a warning log when the template fetch fails, so failed syncs are
visible and debuggable.

## What changed

- `Whatsapp::Providers::WhatsappCloudService#fetch_whatsapp_templates`
now logs a warning with the account id, inbox id, HTTP status code, and
Meta's error message when the response is not successful.
- The inbox id uses safe navigation since sync also runs from the
channel's `after_create` callback, before the inbox record exists.
- The request URL is intentionally not logged, as it contains the access
token as a query param.

## How to reproduce

1. Set up a WhatsApp Cloud inbox with an invalid/expired `api_key`.
2. Trigger a template sync (Inbox settings → sync templates, or wait for
the scheduler).
3. Previously nothing was logged; now a `[WHATSAPP] Template sync failed
for account ... inbox ...` warning appears in the Rails logs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-14 13:35:10 +04:00
Muhsin Keloth
280756b483 fix(meta): disable Instagram replies on Cloud during restriction (#15005)
Agents can no longer send replies in Instagram conversations on Chatwoot
Cloud while the temporary Meta platform restriction is active. The reply
box locks into Private Note mode — the same behavior as an expired
24-hour reply window — so teams can still collaborate internally, with
the existing amber restriction banner above the conversation explaining
why. Self-hosted installations are unaffected.

Follow-up to #14974.


## How to test

1. On a Chatwoot Cloud environment (`isOnChatwootCloud` true), open any
Instagram conversation.
2. The composer should be locked to Private Note mode: the Reply/Private
Note toggle is disabled, and sending creates a private note — even for
conversations within the 24-hour reply window.
3. Switching between conversations should keep the composer in Private
Note mode for Instagram conversations.
4. On a self-hosted environment, Instagram conversations should behave
as before (reply allowed within the messaging window).

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-14 13:30:19 +04:00
Aakash Bakhle
102f19fe41 feat(captain): add FAQ suggestion data model (1/3) (#14977)
Resolved conversations need a separate suggestion layer so repeated FAQ
signals can be grouped without creating untrusted knowledge entries.
This PR adds the persistence foundation only; it introduces no
user-facing behavior by itself.

## Closes

-
[CW-7495](https://linear.app/chatwoot/issue/CW-7495/backend-llm-changes-to-make-conversation-faqs-as-signalssuggestions)
(stacked PR 1/3; the issue is complete after the full stack lands)

## What changed

- Added `captain_faq_suggestions` with question, answer, embedding,
source count, and review status.
- Added `captain_faq_observations` to retain conversation-level signals.
- Added Captain assistant, account, and conversation associations.
- Added vector and lookup indexes for semantic grouping.

## How to test

This layer has no standalone UI behavior. Apply the migration and
confirm Captain assistants can persist open FAQ suggestions with
attached conversation observations.
2026-07-14 14:31:08 +05:30
Aakash Bakhle
1b6a80d84d fix(captain): improve conversation completion evaluation (#14967)
# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes
https://linear.app/chatwoot/issue/AI-136/check-conversation-status-while-auto-resolving

- After 60mins of inactivity, we run a job that decides if pending
conversations are resolvable or need handoff
- the prompt was a bit conservative and didn't have conversation state
context

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally ran a sample eval 

## 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-14 11:45:40 +05:30
Shivam Mishra
9c444315a6 feat: add api_and_webhooks feature flag reconciled from billing plan (#14972)
This introduces a new `api_and_webhooks` account feature flag that will
control access to the token-authenticated API and account webhooks. The
flag is part of the Startup plan features, so paid plans — including
trials of paid plans — get it through the billing reconcile, while
accounts on the default (Hacker) plan don't, with
`manually_managed_features` available as a per-account override. The
flag defaults to enabled, and nothing enforces it yet, so this PR is
behavior-neutral — enforcement lands in a follow-up.

## What changed

- Added `api_and_webhooks` to `features.yml` (first flag on the
`feature_flags_ext_1` column, default enabled).
- Added the flag to `STARTUP_PLAN_FEATURES` in
`Enterprise::Billing::ReconcilePlanFeaturesService`, so all paid tiers
get it and the default plan loses it on reconcile.
- Added the flag to the manually manageable features list so it can be
granted per account via Super Admin.

```rb
# Enables the api_and_webhooks feature for all existing accounts and marks it
# as manually managed so cloud billing reconciles never strip it.
#
# NOT committed to source control — run manually on production.
#
# Usage:
#   bundle exec rails runner enable_api_and_webhooks.rb
#   ACCOUNT_ID=123 bundle exec rails runner enable_api_and_webhooks.rb
#
# Idempotent: accounts already grandfathered are skipped; safe to re-run.

probe = Internal::Accounts::InternalAttributesService.new(Account.new)
abort 'api_and_webhooks is not in valid_feature_list — deploy the feature flag PR first.' unless probe.valid_feature_list.include?('api_and_webhooks')

account_id = ENV.fetch('ACCOUNT_ID', nil)
accounts = account_id.present? ? Account.where(id: account_id) : Account.all

abort "Account with ID #{account_id} not found" if account_id.present? && accounts.empty?

total = accounts.count
puts "Grandfathering api_and_webhooks for #{total} account(s)..."
puts "Started at: #{Time.current}"

updated = 0
skipped = 0
errored = 0

accounts.find_each(batch_size: 500) do |account|
  service = Internal::Accounts::InternalAttributesService.new(account)
  features = service.manually_managed_features

  if features.include?('api_and_webhooks') && account.feature_enabled?('api_and_webhooks')
    skipped += 1
  else
    service.manually_managed_features = features + ['api_and_webhooks'] unless features.include?('api_and_webhooks')
    account.enable_features!('api_and_webhooks')
    updated += 1
  end

  processed = updated + skipped + errored
  puts "Processed #{processed}/#{total}..." if (processed % 1000).zero?
rescue StandardError => e
  errored += 1
  puts "Account #{account.id}: FAILED - #{e.message}"
end

puts "Done! Updated: #{updated}, Skipped: #{skipped}, Errored: #{errored}, Total: #{total}"
```
2026-07-13 18:20:36 +05:30
Shivam Mishra
df7f137657 feat: add captain sessions model [CW-7485] (#14970)
This adds a `captain_sessions` table to log every Captain run, starting
with Assistant Responses and Copilot Responses. Each session records the
assistant, model, credits consumed, the FAQs/documents/scenario that
contributed to the response, and the full run context — giving customers
visibility into how a response was generated and giving us durable stats
on credit, FAQ, and document usage (which today only exist as ephemeral
trace metadata and an aggregate account counter).

## What changed

- New `Captain::Session` model with a `session_type` enum (`assistant`,
`copilot`). The subject (`Conversation` / `CopilotThread`) and result
(`Message` / `CopilotMessage`) classes are inferred from the session
type, so the table stores plain `subject_id` / `result_id` ids.
`result_id` is nullable so failed runs that still consumed credits can
be logged.
- Composite indexes on `[session_type, subject_id]`, `[session_type,
result_id]`, and `[account_id, session_type, created_at]` for lookup and
usage-stats queries.
- Factory and model specs.

This PR is schema + model only; the writer/instrumentation that records
sessions from the assistant and copilot flows will follow.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-13 18:18:20 +05:30
Vishnu Narayanan
056b5eb89d fix: avoid full scan in IMAP email dedup on large inboxes (#14981)
## Description

`Imap::BaseFetchEmailService#email_already_present?` used
`find_by(source_id:)`, which inherits `Message`'s `default_scope {
order(created_at: :asc) }`, adding an `ORDER BY created_at ASC LIMIT 1`
to what is only a presence check.

On inboxes with a large message history, that `ORDER BY` lets Postgres
satisfy the sort by walking `index_messages_on_created_at` instead of
the selective `index_messages_on_source_id`. For a not-yet-seen
`source_id` (every new email) it can scan the whole table before
returning, taking seconds per message. The dedup loop runs with no IMAP
activity in between, so the idle socket is dropped by the mail server
and the fetch job aborts with `closed stream`. The inbox then stops
ingesting mail entirely, while smaller inboxes on the same server keep
working.

`exists?` issues `SELECT 1 ... LIMIT 1` with no `ORDER BY`, so the
planner uses `index_messages_on_source_id` regardless of table size. No
schema change is required. The fix lives in the shared base class, so it
covers both the IMAP and Microsoft fetch paths.

Fixes #14682
2026-07-13 18:15:25 +05:30
Muhsin Keloth
08260f3be7 feat: show crawled document details and FAQ counts in Captain (#14863)
- Add a document details view that surfaces crawled content, source
metadata, and generated FAQ counts.
- Rename the document card action to open details and show the FAQ count
inline in the list.
- Return `responses_count` from the documents API efficiently and expose
document content in the show payload.
- Update related Captain copy to reflect the new details-oriented flow. 


**Preview**
<img width="1640" height="1596" alt="CleanShot 2026-06-26 at 09 25
15@2x"
src="https://github.com/user-attachments/assets/0c408fae-7d37-422a-8869-ece466292cb1"
/>

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-07-13 17:47:00 +05:30
Sony Mathew
11c65f3b9a feat: Intercom import workflow (#14922)
## Description

Adds an admin-only Intercom import workflow under Settings > Data.
Admins can connect an Intercom access token, start named historical
contact/conversation imports, monitor active and previous import runs,
review paginated skip/error logs, download skip logs, and route imported
conversations into source-bucket API inboxes that can be renamed later.

The import path stores durable source mappings, batches Intercom
contact/conversation pages through Sidekiq, records already-imported
records as skipped, and writes historical messages without normal
outbound delivery callbacks. The PR also includes the Intercom import
PRD/TDD document for review context.

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

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

Tested importing using actual data through integration.

Screenshots:

<img width="1800" height="948" alt="Screenshot 2026-07-02 at 10 48
48 PM"
src="https://github.com/user-attachments/assets/e74d9ed6-0bca-47de-b6ef-e589afcddfde"
/>
<img width="1800" height="1008" alt="Screenshot 2026-07-02 at 10 49
03 PM"
src="https://github.com/user-attachments/assets/1bd12fdb-0a47-4287-ac1d-ea308e70a9cd"
/>
<img width="1800" height="1005" alt="Screenshot 2026-07-02 at 10 49
21 PM"
src="https://github.com/user-attachments/assets/3d8145f5-1794-4cc3-b3fa-de5cd80e6ca3"
/>
<img width="1800" height="1002" alt="Screenshot 2026-07-02 at 10 49
38 PM"
src="https://github.com/user-attachments/assets/6f818efd-4193-43c2-84eb-66970dca4490"
/>


Passed locally:

```sh
eval "$(rbenv init -)" && bundle exec rspec spec/models/data_import_spec.rb spec/jobs/data_import_job_spec.rb spec/requests/api/v1/accounts/data_imports_spec.rb spec/requests/api/v1/accounts/integrations/intercom_spec.rb spec/jobs/data_imports/intercom/import_jobs_spec.rb spec/services/data_imports/intercom/importer_spec.rb spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb spec/services/data_imports/intercom/source_bucket_spec.rb
```

```sh
eval "$(rbenv init -)" && bundle exec rubocop app/controllers/api/v1/accounts/data_imports_controller.rb app/controllers/api/v1/accounts/integrations/intercom_controller.rb app/jobs/data_imports/intercom app/models/data_import.rb app/models/data_import_error.rb app/models/data_import_item.rb app/models/data_import_mapping.rb app/models/integrations/hook.rb app/policies/data_import_policy.rb app/policies/hook_policy.rb app/services/data_imports/intercom db/migrate/20260702000000_expand_data_imports_for_intercom_imports.rb db/migrate/20260702000001_create_data_import_items.rb db/migrate/20260702000002_create_data_import_mappings.rb db/migrate/20260702000003_create_data_import_errors.rb spec/jobs/data_imports/intercom spec/requests/api/v1/accounts/data_imports_spec.rb spec/requests/api/v1/accounts/integrations/intercom_spec.rb spec/services/data_imports/intercom
```

```sh
pnpm exec eslint app/javascript/dashboard/api/dataImports.js app/javascript/dashboard/api/integrations.js app/javascript/dashboard/routes/dashboard/settings/data/Index.vue app/javascript/dashboard/routes/dashboard/settings/data/Show.vue app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js app/javascript/dashboard/routes/dashboard/settings/integrations/Intercom.vue app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js app/javascript/dashboard/routes/dashboard/settings/settings.routes.js app/javascript/dashboard/components-next/sidebar/Sidebar.vue app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue
```

```sh
git diff --check
```

Note: the RSpec boot logs the existing local `chatwoot_dev` purge
warning because other database sessions are open, then continues and
completes with 52 examples, 0 failures.

## Checklist:

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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-13 15:25:07 +05:30
Muhsin Keloth
8fc5c7a5c8 feat: add captain general guidelines migration helpers (#14909)
This PR adds internal tooling and planning docs for migrating existing
Captain assistant instructions into the new General Guidelines
structure.


**Summary**

This PR adds a controlled migration path for moving existing Captain V1
assistant instructions into the structured Captain architecture.

It introduces a classifier that reads the current `config.instructions`
and produces reviewed migration drafts with separate sections for:

- assistant description / business context
- response guidelines
- guardrails
- scenario candidates
- conversation messages
- FAQ/document candidates
- needs-review items

The migration is intentionally staged. It only targets V1-style
assistants that still have custom instructions, are connected to
inboxes, and do not already have structured response guidelines,
guardrails, or scenario records.

When applied, the task writes the extracted business context to the
assistant description, response guidelines to `response_guidelines`,
guardrails to `guardrails`, and stores scenario candidates / FAQ
candidates / review notes under `config["assistant_migration"]`.
Scenario candidates are also flattened into response guidelines for now
so customer behavior is preserved before we create real
`Captain::Scenario` records in a later rollout.

The applier stores the original assistant values under migration
metadata so conversation message config can be restored if needed. It
does not create scenario records yet.

**How to generate drafts**

For specific assistant IDs:

```bash
bundle exec rake captain:assistant_migration:generate \
  IDS=546,636,819 \
  LIMIT=0 \
  OUTPUT=tmp/captain_migration_drafts.jsonl
```

For the first 50 eligible assistants:

```bash
bundle exec rake captain:assistant_migration:generate \
  OUTPUT=tmp/captain_migration_drafts.jsonl
```

For all eligible assistants:

```bash
bundle exec rake captain:assistant_migration:generate \
  LIMIT=0 \
  OUTPUT=tmp/captain_migration_drafts.jsonl
```

**How to apply drafts**

Dry run first:

```bash
bundle exec rake captain:assistant_migration:apply \
  INPUT=tmp/captain_migration_drafts.jsonl \
  DRY_RUN=true
```

Apply changes:

```bash
bundle exec rake captain:assistant_migration:apply \
  INPUT=tmp/captain_migration_drafts.jsonl \
  DRY_RUN=false
```

**How to restore conversation messages**

If extracted `welcome_message`, `handoff_message`, or
`resolution_message` need to be reverted to their pre-migration values:

```bash
bundle exec rake captain:assistant_migration:restore_messages \
  IDS=546,636,819 \
  DRY_RUN=true
```

```bash
bundle exec rake captain:assistant_migration:restore_messages \
  IDS=546,636,819 \
  DRY_RUN=false
```

**Notes**

- `LIMIT=0` means no limit.
- `generate` overwrites the output file.
- The apply task skips assistants that are no longer V1 migration
candidates.
- This PR does not create `Captain::Scenario` records; scenario
candidates are staged in assistant config for a future migration.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-07-13 14:56:09 +05:30
Shivam Mishra
7caa4e0bbc fix: pin pnpm version in CircleCI node orb install step (#14999)
CI runs were failing because the CircleCI Node orb's pnpm auto-install
step matches the wrong "version" field in pnpm's package.json, producing
an invalid install command since pnpm 11.12.0.

Ref: https://github.com/CircleCI-Public/node-orb/issues/262

### What changed
- Pinned `pnpm-version: 10.2.0` (matching `packageManager` in
`package.json`) on all three `node/install-pnpm` steps in
`.circleci/config.yml`, bypassing the orb's broken version
auto-detection.
2026-07-13 14:48:47 +05:30
Tanmay Deep Sharma
9c10fe4eb1 fix: default assignment age exclusion (#14998)
## Description
Auto-assignment was skipping the 7-day staleness check entirely for
inboxes that don't have an assignment policy attached. Those inboxes
would pull unassigned conversations of any age off the backlog and hand
them to agents — including conversations untouched for months — while
the activity log still credited "Default Policy" for the assignment.
This makes the default behaviour match what that label implies: with no
policy configured, conversations with no activity in the last 7 days are
now excluded, the same window a freshly created policy uses.

## Type of change

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


## 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
2026-07-13 14:01:47 +05:30
Shivam Mishra
6b3800e25b fix: pre-chat form contact custom attributes lost for existing contacts (#14956)
Contact custom attributes filled in the pre-chat form (e.g. a CPF field)
were silently lost when the visitor's email or phone matched an existing
contact. The widget sent the attributes in a separate request that raced
the conversation create: creating the conversation merges the widget
contact into the existing contact, so the attribute update landed on the
destroyed contact and vanished without an error. New visitors were
unaffected, which made the bug hard to spot.

The fix sends contact custom attributes inside the same request that
identifies the contact. The widget conversation create endpoint now
accepts `contact.custom_attributes` and applies them through
`ContactIdentifyAction` in the same transaction as the merge, so the
submitted values always land on the surviving contact. The campaign path
folds the attributes into the existing contact update call the same way.

<details>
<summary>Reproduction script (rails runner, concurrent
threads)</summary>

```ruby
# Concurrent reproduction of the pre-chat form race that loses contact
# custom attributes when the visitor's email matches an existing contact.
#
# The old widget fired two unawaited requests on pre-chat submit. Each
# iteration replays them as real concurrent threads:
#   - create thread = POST /widget/conversations: resolves the widget contact,
#     then runs ContactIdentifyAction (which merges the widget contact into the
#     existing contact) inside a transaction held open while the conversation
#     and message are created.
#   - patch thread = PATCH /widget/contact: resolves the widget contact via its
#     contact inbox and applies the custom attributes through
#     ContactIdentifyAction, exactly like Widget::ContactsController#update.
#
# Run with: bundle exec rails runner confirm_prechat_race.rb
# Creates throwaway contacts on Account.first and deletes them afterwards.

ITERATIONS = 20
CPF = '123.456.789-09'.freeze

account = Account.first!
inbox = account.inboxes.first!
losses = 0

ITERATIONS.times do |i|
  existing = account.contacts.create!(name: 'Existing Contact', email: "race-existing-#{SecureRandom.hex(6)}@example.com")
  temp = account.contacts.create!(name: 'Widget Visitor')
  contact_inbox = ContactInbox.create!(contact: temp, inbox: inbox, source_id: SecureRandom.uuid)

  begin
    create_request = Thread.new do
      ActiveRecord::Base.connection_pool.with_connection do
        widget_contact = ContactInbox.find(contact_inbox.id).contact # set_contact before_action
        ActiveRecord::Base.transaction do
          ContactIdentifyAction.new(
            contact: widget_contact,
            params: { email: existing.email, phone_number: nil, name: 'Widget Visitor' },
            retain_original_contact_name: true,
            discard_invalid_attrs: true
          ).perform
          sleep(0.02) # conversation + message creation keeps the transaction open
        end
      end
    end

    patch_request = Thread.new do
      ActiveRecord::Base.connection_pool.with_connection do
        sleep(rand * 0.02) # network jitter between the two requests
        widget_contact = ContactInbox.find(contact_inbox.id).contact # set_contact before_action
        ContactIdentifyAction.new(
          contact: widget_contact,
          params: { custom_attributes: { cpf: CPF } },
          discard_invalid_attrs: true
        ).perform
      end
    end

    create_request.join
    patch_request.join

    survivor = existing.reload
    if survivor.custom_attributes['cpf'] == CPF
      puts "iteration #{i + 1}: CPF survived"
    else
      losses += 1
      puts "iteration #{i + 1}: CPF LOST (survivor custom_attributes: #{survivor.custom_attributes.inspect})"
    end
  ensure
    account.contacts.where(id: [existing.id, temp.id]).find_each(&:destroy!)
  end
end

puts
puts "#{losses}/#{ITERATIONS} iterations lost the CPF -- race #{losses.positive? ? 'confirmed' : 'not reproduced in this run'}"
```

Result: **20/20 iterations lose the CPF** (consistent across repeated
runs). The conversation create holds its transaction open across the
contact merge, so the fast attribute update either resolves the
soon-to-be-destroyed widget contact or blocks on its row lock and then
updates 0 rows — silently, with no error. This is why the customer sees
the loss every time, not intermittently.

Note: the script replays the old two-request flow at the service layer,
so it reproduces the loss even with this fix applied — the fix works by
removing the second request from the widget, not by changing the raced
code paths. The new request spec (`saves contact custom attributes on
the surviving contact when merged into an existing contact`) covers the
fixed contract.

</details>

## Closes

- Reported via support conversation:
https://app.chatwoot.com/app/accounts/1/conversations/84465

## How to reproduce

1. Create a website inbox with a pre-chat form that has a contact custom
attribute field (e.g. CPF).
2. Create a contact with a known email address.
3. As a visitor, open the widget and fill the pre-chat form using that
same email plus a value for the custom attribute.
4. Before: the attribute never appears on the contact profile. After: it
is saved on the existing contact, overwriting any stale value.

## What changed

- `POST /api/v1/widget/conversations` now permits
`contact.custom_attributes` and passes it to `ContactIdentifyAction`,
which deep-merges it atomically with the contact merge.
- The widget pre-chat form sends contact custom attributes inside the
conversation create payload instead of a separate `setCustomAttributes`
call; the campaign path includes them in the `contacts/update` payload.
2026-07-13 13:51:58 +05:30
Tanmay Deep Sharma
b560720e08 feat: allow switching embedded signup whatsapp embedded inboxes to manual setup (#14975)
Since embedded signup has been disabled on production, WhatsApp inboxes
that were created through it have no way to be managed going forward.
This PR lets admins transfer an embedded signup inbox to a manual Cloud
API setup: the inbox settings Configuration tab now shows the webhook
verification token and a "Switch to Manual Setup" form (pre-populated
with the Phone Number ID, Business Account ID, and API key stored during
the embedded signup journey) instead of the old Reconfigure button.

Saving the form updates the channel's `provider_config` without the
`source: embedded_signup` marker, so the inbox becomes a regular
manually-configured WhatsApp Cloud inbox. The backend re-validates the
submitted credentials against Meta before accepting the change.

## How to test

1. Open the settings page of a WhatsApp inbox created via embedded
signup → Configuration tab.
2. The Reconfigure button is gone; you see the webhook verification
token and a Switch to Manual Setup form pre-filled with the stored
credentials.
3. Configure the webhook in your own Meta app using the verification
token, enter a permanent access token from that app, and click "Switch
to Manual Setup".
4. On success the page switches to the standard manual configuration
view (verify token, API key update), and messaging continues to work
with the new credentials. Invalid credentials are rejected with an
error.

## What changed

- `ConfigurationPage.vue`: replaced the embedded-signup Reconfigure
section (and the hidden reauthorize component) with the manual transfer
form.
- New `WHATSAPP_MANUAL_TRANSFER_*` translation keys.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-13 13:48:38 +05:30
Aakash Bakhle
cc87429903 feat(captain): expand assistant description limit (#14985)
# Pull Request Template

## Description

Increases description for Captain.

Why?
We are planning to include business context in description and 255 char
limit on the column and 200 char limit on the UI are very limiting to
get proper context.

## Type of change

Improvement to accommodate business context

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally

## 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
2026-07-13 12:59:36 +05:30
Muhsin Keloth
98154bbeab fix(meta): show restriction alerts for inbox setup (#14974)
Instagram inbox creation and WhatsApp embedded signup on Chatwoot Cloud
now reflect the temporary Meta restriction. Instagram is hidden from
onboarding on Cloud, while the regular Instagram inbox creation page
shows a disabled action with a status-linked amber warning. WhatsApp
embedded signup on Cloud stays visible with its connect action disabled.
WhatsApp Call setup always uses the manual WhatsApp form.

Existing Instagram conversations and Instagram inbox settings on Cloud
also show amber warning banners with the public incident link.
Self-hosted installations keep their existing Instagram, WhatsApp, and
WhatsApp Call setup behavior because the temporary restriction is based
only on the Chatwoot Cloud environment check.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-10 16:22:49 +04:00
Sivin Varghese
03a1b1dbc1 chore: insert resolved variable value in reply editor (#14921)
# Pull Request Template

## Description

This PR makes reply editor variables insert their resolved value (for
example, the contact's name) instead of the raw `{{contact.name}}`
placeholder, matching canned response behavior. This works both when
picking a variable from the `{{` menu and when an agent manually types
out `{{contact.name}}` — it resolves the moment the closing `}}` is
typed.

If a variable has no value, the `{{placeholder}}` is kept so the backend
can still resolve it when the message is sent. Private notes are left
untouched.

For safety, a resolved value that itself contains Liquid syntax `({{ }}`
or `{% %})` also keeps its placeholder, so customer-controlled fields
can never inject Liquid into the outgoing message.

Fixes
https://linear.app/chatwoot/issue/CW-7528/reply-editor-inserts-variable-placeholder-instead-of-the-value

## Type of change

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

## How Has This Been Tested?

1. Open a conversation and add a reply.
2. Type `{{` and pick a variable that has a value (e.g. Contact name) →
it inserts the actual value.
3. Manually type `{{contact.name}}` and close the braces → it
auto-resolves to the value.
4. Insert/type a variable with no value → the `{{placeholder}}` stays;
confirm it resolves correctly on send.
5. Repeat in a private note → placeholders are left as-is.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-10 17:24:34 +05:30
Sony Mathew
848e94bcf2 fix: throttle filtered unread count rebuilds (#14980)
Reduces database pressure from filtered unread-count cache rebuilds
during high-traffic account rollouts by serving stale snapshots longer
and limiting inline saved-filter rebuild fanout.

## Closes

None

## What changed

- Increase filtered unread-count refresh throttling from 30 seconds to 5
minutes.
- Increase the stale snapshot window from 30 minutes to 1 hour.
- Reduce inline saved-filter count rebuilds per request from 10 to 3.
- Update unread-count specs to assert refresh and stale behavior through
the shared constants.

## How to test

- Enable `conversation_unread_counts` and `unread_count_for_filters` for
an account with conversation custom filters.
- Open the dashboard and verify unread-count badges still return values.
- Mutate conversations and verify stale filtered counts are served while
rebuilds are throttled, instead of repeatedly rebuilding every 30
seconds.
2026-07-10 17:23:00 +05:30
Tanmay Deep Sharma
56dc580f50 feat: support manual setup for WhatsApp calls inbox creation (#14976)
Re-enables the "WhatsApp Calls" inbox creation option, which disappeared
when embedded signup was disabled on production. The channel card now
only requires the `channel_voice` account feature, and the creation flow
offers the manual WhatsApp Cloud API setup form. Once the channel is
created with manual credentials, calling is enabled automatically — the
same post-setup step the embedded signup flow used to perform.

When an installation has embedded signup configured, the flow still uses
it; the manual form is the fallback (and the effective path on Chatwoot
Cloud today).

## How to test

1. Enable the `channel_voice` feature on the account.
2. Go to Add Inbox → the "WhatsApp Calls" card is visible again → select
it.
3. Fill in the manual Cloud API credentials (inbox name, phone number,
phone number ID, business account ID, API key) and submit.
4. The inbox is created and calling is enabled:
`provider_config.calling_enabled` is true and the Calls tab toggle is
on. If the number isn't enrolled in the Business Calling API, an alert
explains the enable failure but the messaging inbox is still created.

## What changed

- `ChannelItem.vue`: the `whatsapp_call` card is gated only on
`channel_voice` (previously also required the embedded signup app ID).
- `CloudWhatsapp.vue`: new `enableCallingOnComplete` prop that calls the
enable-calling API after channel creation.
- `WhatsappCall.vue`: renders embedded signup when available, otherwise
the manual setup form — both with calling enabled on completion.
2026-07-10 15:24:14 +05:30
Muhsin Keloth
35fcd56ba9 feat: add support action to suspended account page (#14969)
Updates the suspended account page with the revised policy copy and adds
a visible Contact support action that opens the embedded Chatwoot
support widget.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-09 16:45:47 +04:00
Aakash Bakhle
d57354c8b5 feat: tighten conversation FAQ generation prompt (#14957)
Tightens the resolved-conversation FAQ generator so it only proposes
durable, reusable FAQ candidates supported by human support-agent
messages. The implementation now sends a conversation-FAQ-specific
transcript to the LLM: customer messages plus real human support-agent
messages only, excluding bot, private, activity, and template messages.

## Closes

-
https://linear.app/chatwoot/issue/CW-7494/tighten-conversation-faq-generation-prompt

## What changed

- Added a human-only transcript builder in `ConversationFaqService`
instead of using the generic `conversation.to_llm_text` output.
- Excluded bot/agent-bot messages before the LLM call, which removes the
main bot-line leakage class deterministically.
- Preserved native-channel human replies where outgoing messages are
stored as `external_echo` without a `User` sender.
- Kept a prompt decision gate requiring each FAQ to be backed by a
complete public human-agent answer.
- Added generic no-FAQ classes for spam, wrong-service conversations,
private account/payment/order/certificate/troubleshooting cases, support
workflow mechanics, and direct-link/file/quote outputs.
- Added a separate `conversation_faq_generation` model route defaulting
to `gpt-5.2`, while keeping `document_faq_generation` on its existing
`gpt-4.1-mini` default. Conversation FAQ generation passes that feature
default ahead of the legacy global `CAPTAIN_OPEN_AI_MODEL` setting
unless an account-level override is configured.
- Kept the prompt domain-neutral so it can still generate reusable
product, service, policy, setup, and process FAQs outside SaaS contexts.

## Sampling notes

- Production Langfuse traces showed `llm.captain.conversation_faq` calls
using `gpt-4.1` in the sampled account set.
- Locally, `Llm::FeatureRouter.resolve(feature:
'conversation_faq_generation')` now resolves to `gpt-5.2`.
- Reviewed recent production `llm.captain.conversation_faq` traces
across 13+ accounts in compact form.
- Replayed 20 full traces across 10 accounts/domains, including
education, hosting, retail/auto, APIs, logistics, tax/fiscal workflows,
and Chatwoot account 1.
- Explicit `gpt-5.2` replay with human-only conversation history
returned no FAQ for 15/20 traces.
- A comparison replay with `gpt-4.1-mini` returned no FAQ for only 7/20
traces, bringing back several private/order/payment/support-workflow
cases.
- Remaining non-empty `gpt-5.2` outputs are now mostly
borderline/possibly useful human-agent-derived FAQs rather than obvious
bot-sourced answers.

## How to test

- Resolve conversations where the answer came only from the bot; no
pending FAQ should be generated.
- Resolve spam, unrelated, wrong-service, or private
payment/order/account conversations; no pending FAQ should be generated.
- Resolve conversations that require account/order/payment/login/private
verification or a human handoff; no pending FAQ should be generated.
- Resolve a conversation where a human agent gives a stable, reusable
help-center answer; the generated pending FAQ should be general and
self-contained.
2026-07-09 17:47:47 +05:30
Shivam Mishra
8d2ef4ec5e feat: assistant overview drilldown reports [CW-7408] (#14920)
<img width="2616" height="1716" alt="CleanShot 2026-07-09 at 14 22
42@2x"
src="https://github.com/user-attachments/assets/504e58df-0243-4cb9-a811-62504fdcb0ac"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-09 16:25:22 +05:30
Muhsin Keloth
bddb561546 chore: improve account suspended message (#14968)
Updates the suspended account screen copy to use clearer policy and
safety-oriented language, while giving users a direct support path if
they believe the suspension is a mistake.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-09 13:01:08 +04:00
Vishnu Narayanan
20227403ce fix: return public contact inbox payload after update (#14946)
# Pull Request Template

## Description
This keeps the public inbox contact update response on the public
contact inbox serializer shape after applying the contact identify
update.

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

- `bundle exec rspec
spec/controllers/public/api/v1/inbox/contacts_controller_spec.rb`

## Checklist:

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

Fixes
[https://linear.app/chatwoot/issue/CW-6937](https://linear.app/chatwoot/issue/CW-6937)
Fixes
[https://linear.app/chatwoot/issue/CW-7464](https://linear.app/chatwoot/issue/CW-7464)
Fixes
[https://linear.app/chatwoot/issue/CW-7457](https://linear.app/chatwoot/issue/CW-7457)
2026-07-09 13:50:01 +05:30
Vishnu Narayanan
0a13dbedfb fix: force account picker on microsoft email inbox OAuth (#14794)
## Description

The Microsoft authorize URL had no `prompt` parameter. With Microsoft's
default behavior, a browser carrying an active Microsoft session is
silently signed in to that account. If the same account is already
attached to an inbox in the same Chatwoot account, the callback treats
the OAuth response as a re-auth and routes the user to the existing
inbox settings page instead of letting them create the new inbox they
intended.

Adding `prompt=select_account` interrupts SSO and always presents the
Microsoft account picker, so the user explicitly chooses the mailbox
they want to connect.

This is distinct from `prompt=consent`, which was removed in
[#13962](https://github.com/chatwoot/chatwoot/pull/13962) to fix the
admin consent loop. `select_account` only interrupts SSO and does not
retrigger the consent dialog.

Closes INF-76

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

`bundle exec rspec
spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb`
(3 examples, 0 failures). The regression test introduced in
[#13962](https://github.com/chatwoot/chatwoot/pull/13962) is updated to
assert `prompt=select_account` instead of asserting absence of the
parameter.

## 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-09 13:49:33 +05:30
Muhsin Keloth
8d554f2b74 chore(inbox): disable Instagram inbox creation (#14964)
Reverts chatwoot/chatwoot#14955
2026-07-09 10:02:02 +04:00
Sony Mathew
4854fb7b4b chore: upgrade websocket-driver (#14961)
# Pull Request Template

## Description

Updates `websocket-driver` from `0.7.7` to `0.8.2` to resolve the
bundle-audit findings for CVE-2026-54463, CVE-2026-54464,
CVE-2026-54465, and GHSA-2x63-gw47-w4mm.

This is a lockfile-only transitive dependency update through Rails
Action Cable. No application code changes are included.

Fixes # (issue)
N/A

## Type of change

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

## How Has This Been Tested?

- `bundle exec bundle audit update && bundle exec bundle audit check -v`
- `bundle check && bundle exec ruby -e 'require "action_cable"; require
"action_cable/connection/client_socket"; require "websocket/driver";
puts "actioncable=#{ActionCable::VERSION::STRING}"; puts
"websocket-driver=#{Gem.loaded_specs["websocket-driver"].version}"; puts
"driver_rack=#{WebSocket::Driver.respond_to?(:rack)}"'`
- `POSTGRES_DATABASE=chatwoot_test_3d6a RAILS_ENV=test bundle exec rails
db:prepare && POSTGRES_DATABASE=chatwoot_test_3d6a bundle exec rspec
spec/listeners/action_cable_listener_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
- [ ] I have commented on my code, particularly in hard-to-understand
areas (not applicable; lockfile-only change)
- [ ] I have made corresponding changes to the documentation (not
applicable; dependency security update)
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works (not applicable; dependency security update)
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules (not applicable)
2026-07-08 15:52:40 -07:00
Sony Mathew
66cfb26c77 feat: Add unread count filters feature flag (1/6) (#14885)
## Description

Adds the account-level `unread_count_for_filters` feature flag as the
dark-launch gate for filtered sidebar unread counts. This reuses the
deprecated `quoted_email_reply` flag slot, resets the reused bit for
existing accounts, and removes stale defaults so new accounts do not
reference the old flag.

This also adds the feature where we are now calculating the unread counts for built in filters like mentions, participating and unattended along with unread count for saved filters/folders.

Closes
[CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders)

## 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
2026-07-08 23:50:40 +05:30
Shivam Mishra
b9536fb8ed fix: use reserved example domain in SAML placeholders (#14958) 2026-07-08 21:55:38 +05:30
Sony Mathew
873d16f54c feat: Extend account feature flag storage (#14947)
# Pull Request Template

## Description

Extends account-level feature flags by adding a second bigint bitset
column, `feature_flags_ext_2`, while preserving the existing
`flag_shih_tzu` feature check and enable/disable APIs. Existing flags
continue to live on `feature_flags`; future flags can opt into the
extension column through `config/features.yml` metadata.

Fixes
[CW-7238](https://linear.app/chatwoot/issue/CW-7238/feature-flag-extension)

## Type of change

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

## How Has This Been Tested?

- `eval "$(rbenv init -)" && RAILS_ENV=test
POSTGRES_DATABASE=chatwoot_test_31a6 bundle exec rspec
spec/models/concerns/featurable_spec.rb spec/models/account_spec.rb
spec/lib/config_loader_spec.rb
spec/controllers/platform/api/v1/accounts_controller_spec.rb
spec/controllers/super_admin/accounts_controller_spec.rb
spec/enterprise/models/account_spec.rb` - 144 examples, 0 failures
- `eval "$(rbenv init -)" && bundle exec rubocop
app/models/concerns/featurable.rb app/models/account.rb
db/migrate/20260706215758_add_feature_flags_ext_2_to_accounts.rb
spec/models/concerns/featurable_spec.rb spec/models/account_spec.rb
spec/lib/config_loader_spec.rb spec/enterprise/models/account_spec.rb` -
7 files inspected, no offenses detected
- `ruby -ryaml -e "features =
YAML.safe_load(File.read('config/features.yml')); abort unless
features.size == 63; puts features.group_by { |f| f['column'] ||
'feature_flags' }.transform_values(&:size).inspect"` - `{"feature_flags"
=> 63}`
- `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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-08 17:38:26 +05:30
Muhsin Keloth
d3c588ff10 chore(inbox): re-enable Instagram inbox creation (#14955)
Brings the Instagram channel back in inbox creation and onboarding.
Instagram was temporarily disabled along with WhatsApp embedded signup
in #14943; this re-enables Instagram while keeping WhatsApp embedded
signup and WhatsApp Call inbox creation disabled.

Fixes https://linear.app/chatwoot/issue/CW-7549/enable-instagram

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-08 17:37:46 +05:30
Tanmay Deep Sharma
f6c18f5225 feat: account calls dashboard index endpoint (#14780)
## Description

Adds a backend endpoint that powers an account-wide calls dashboard,
letting users list and filter all calls in the account.

## Linear Ticket
- https://linear.app/chatwoot/issue/UPM-28/voice-call-dashboard-view

## Type of change

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

## 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
2026-07-08 15:31:03 +05:30
Sojan Jose
0e07a27c74 fix: enforce inbox limits at model level (#14949)
Fixes https://linear.app/chatwoot/issue/CW-7559/inbox-limit-abuse

## Why

The regular inbox API checked limits in the controller, but WhatsApp
embedded signup creates inboxes through a service using `Inbox.create!`.
That let Enterprise account inbox limits be skipped for embedded signup.

## What this change does

- Adds an Inbox create-time validation hook in OSS and implements the
limit check in the Enterprise Inbox module.
- Removes the duplicate controller/helper limit check so the model is
the single enforcement point.
- Preserves the existing `402 Payment Required` API response for account
inbox limit failures.
- Keeps updates to existing inboxes allowed when an account is already
at its inbox limit.

## Validation

- `bundle exec rspec
spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
spec/enterprise/models/inbox_spec.rb`
2026-07-08 13:18:07 +04:00
Muhsin Keloth
ce8c8e9a11 fix: sanitize control characters in team names (#14865)
Team names created via the API could contain control characters (for
example a trailing newline). Because the team-delete confirmation dialog
requires you to retype the team name and matches it against the stored
value, a hidden control character meant the typed name never matched —
leaving the team impossible to delete from the UI. This sanitizes team
names on save so they stay clean and deletable.

#### How to reproduce
1. Create a team via `POST /api/v1/accounts/{account_id}/teams` with
`{"name": "test\n"}`.
2. The team is created with the trailing newline stored in `name`.
3. In **Settings → Teams**, click delete and type the team name to
confirm — the match fails, so the team cannot be deleted.

#### What changed
- `app/models/team.rb`: the existing `before_validation` now strips
control characters and surrounding whitespace before downcasing the
name. Names that reduce to blank (e.g. only newlines/tabs) are rejected
loudly by the existing `presence` validation.
- Fixing at the model layer covers the API and every other create/update
path, rather than relying on the frontend confirm-dialog `.trim()`
(which only handles leading/trailing whitespace, not internal control
characters).

Note: this prevents new malformed names. Any team already saved with a
control character can be made deletable again simply by renaming it (an
update re-runs the same sanitization).

| Input | Stored as | Result |
|---|---|---|
| `"test\n"` | `"test"` | valid, deletable |
| `"te\nst"` (internal) | `"test"` | valid |
| `"\t\n "` (only control/ws) | — | rejected: "Name must not be blank" |
| `"Customer Support"` | `"customer support"` | unchanged behavior |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:04:24 -07:00
Shivam Mishra
29d0b92f1c fix: captain hours saved metric (#14948)
Reworks the Captain assistant "hours saved" metric so it produces a
believable number. It previously multiplied assistant reply count by
customer reply-wait time, which inflated the figure into the millions of
hours. It now estimates saved time as replies × a fixed ~2 min per-reply
effort assumption. On the overview card the value renders in days once
it passes 100 hours, and the label/hint copy was updated to match.

## How to test
1. Open the Captain assistant overview page.
2. Check the "Time saved" card shows a sensible value (hours, or days
past 100h).
3. Hover the hint and confirm it reflects the per-reply estimate.
2026-07-07 19:16:24 +05:30
Shivam Mishra
a5fcecb3f6 feat: assistant overview page [CW-7408] (#14889)
This PR adds a Captain Assistant **Overview** page to show some KPI
metrics (conversations handled, auto-resolution, handoff, hours saved,
reopen-after-resolve, conversation depth) with trend deltas vs the
previous window, a real knowledge card, and a lazily-loaded, cached LLM
welcome summary.

### Highlights

- **Two contextual banners** on the overview:
- **Inbox banner** — prompts the user to connect an inbox when the
assistant has none, so it can actually do work.
- **Coverage banner** — warns when FAQ coverage is below 85% with more
than 100 responses pending review, linking straight to the pending
queue. Dismissal persists per-assistant for 24h via localStorage.
- **Batched stats builder** (`Captain::AssistantStatsBuilder`) computes
both windows in single FILTER-aggregated scans to cut round trips,
behind new `stats`/`summary` endpoints.
- **Cards included but intentionally left dummy / not rendered yet:**
`ResponseQualityCard` (flagged responses) and `CreditUsageCard` (credit
usage + daily chart). Credits are an account-wide counter with no
per-assistant or daily history, so there is no real data to back them
yet; they ship in the codebase but are not wired into the page.

### Index migration

- Replaces `index_messages_on_sender_type_and_sender_id` with
`index_messages_on_sender_and_created` `(sender_type, sender_id,
created_at)`.
- **Why it helps:** the per-assistant windowed lookups filter `sender_*`
*and* a `created_at` range. The old 2-column index matched every
lifetime row for the assistant and filtered the time slice at the heap
(~89% of rows discarded); adding `created_at` as a range column lets
Postgres scan only the window, and fixes the row-count estimate so the
planner picks a hash join over a nested loop on `reporting_events`.
- **Why dropping the old index is safe:** the new index is a left-prefix
superset `(sender_type, sender_id, ...)`, so every query the old one
served is still served. No code references it by name, and dropping it
keeps write amplification on `messages` neutral. Built/dropped with
`CONCURRENTLY` and `if_not_exists`/`if_exists` guards.


## Preview

<img width="2572" height="1754" alt="CleanShot 2026-06-29 at 22 38
51@2x"
src="https://github.com/user-attachments/assets/3798d09e-7850-48e4-b2cd-508533f15cea"
/>

## Banners

#### Inbox connect alert

<img width="2178" height="612" alt="CleanShot 2026-06-30 at 14 26 55@2x"
src="https://github.com/user-attachments/assets/373c371c-bb7d-4291-a0f9-620673078302"
/>

#### Coverage alert
<img width="2178" height="612" alt="CleanShot 2026-06-30 at 14 25 41@2x"
src="https://github.com/user-attachments/assets/e12d6308-11b6-4ba2-88a2-8a3077dd3e8f"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-07 17:54:13 +05:30
Vishnu Narayanan
1e7218d439 fix: compare HMAC identity hashes securely (#14945)
This switches widget and public inbox HMAC identity checks from direct
string equality to constant-time comparison with a length guard.

Fixes
[https://linear.app/chatwoot/issue/CW-6937](https://linear.app/chatwoot/issue/CW-6937)
Fixes
[https://linear.app/chatwoot/issue/CW-7464](https://linear.app/chatwoot/issue/CW-7464)
Fixes
[https://linear.app/chatwoot/issue/CW-7227](https://linear.app/chatwoot/issue/CW-7227)
2026-07-07 17:37:22 +05:30
Vishnu Narayanan
c8bfbadae2 fix: validate identifier hash on widget contact update (#14884)
## Description

The widget contact update endpoint (`PATCH /api/v1/widget/contact`)
applied updates that set an `identifier` without validating the
`identifier_hash`, unlike `set_user`, which already does. This change
runs the same validation on the update path whenever an `identifier` is
supplied, keeping identity validation consistent across the widget
contact endpoints.

Anonymous updates that don't pass an identifier (prechat
name/email/phone and custom attributes) keep working unchanged,
including on inboxes with mandatory identity validation.

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


---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-07 16:40:19 +05:30
Aakash Bakhle
83dda621c5 feat: default new accounts to captain v2 (#14917)
# Pull Request Template

## Description

defaults new accounts to captain v2

## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and specs

## 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-07 16:21:51 +05:30
Sony Mathew
eab859cd04 fix: Sanitize query canceled API errors (#14933)
# Pull Request Template

## Description

Message creation API failures caused by PostgreSQL query cancellations
now return a generic retryable error instead of exposing raw database
internals such as `PG::QueryCanceled`, tuple identifiers, or relation
names to customers. Existing validation failures continue to return
their specific validation messages.

Closes
[CW-7538](https://linear.app/chatwoot/issue/CW-7538/do-not-expose-pgquerycanceled-details-in-messages-api-errors)

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

Reproduced the messages API path by raising
`ActiveRecord::QueryCanceled` from `Messages::MessageBuilder` and
verified the response contains the customer-safe localized error without
`PG::QueryCanceled` details.

Validation run locally:

- `bundle exec rspec
spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb`
- `bundle exec rubocop
app/controllers/concerns/request_exception_handler.rb
spec/controllers/api/v1/accounts/conversations/messages_controller_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
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-07 16:20:52 +05:30
Sojan Jose
b823764199 refactor: optimize agent bot assignment dropdown (#14866) 2026-07-07 14:25:54 +05:30
Muhsin Keloth
397ac2e18a chore(inbox): temporarily disable Instagram and WhatsApp embedded signup inbox creation (#14943)
This temporarily turns off inbox creation for channels that rely on
Meta's embedded signup — the WhatsApp Cloud signup popup, Instagram
(OAuth-only), and WhatsApp Call. WhatsApp Cloud inbox creation now falls
back to the manual API-key form, while the Instagram and WhatsApp Call
tiles appear disabled. Both channels are also hidden from the
new-account onboarding flow. Existing inboxes are not affected.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-07 12:18:17 +04:00
Tanmay Deep Sharma
11deffdd5d feat: billing brl pix new users (#14617)
## Linear ticket
- https://linear.app/chatwoot/issue/CW-7253/billing-brl-pix-new-users

## Description

New accounts that sign up in Brazilian Portuguese are now billed in BRL
instead of USD. Their Stripe customer is created with a Brazil address
and Portuguese locale (so the Stripe portal offers Real prices and PIX),
and the AI credit top-up flow shows packages priced in the account's
billing currency. Currency support is config-driven, so adding another
currency later is a configuration change rather than a code change.


## Type of change

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

## How Has This Been Tested?

- https://www.loom.com/share/c8d3d08c1b844ed6b820438d4209491a

## Screenshot
<img width="904" height="440" alt="image"
src="https://github.com/user-attachments/assets/6f19fad8-e6af-46ea-b99f-b0265bb9eeec"
/>


## 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
2026-07-06 16:35:38 +05:30
Ask Bjørn Hansen
8818d276b9 fix(captain): read OpenAI key from InstallationConfig in article search terms (#14915)
generate_article_search_terms still pulled ENV['OPENAI_API_KEY'], left
over from before the Jan 2025 Captain migration moved the key into
InstallationConfig as CAPTAIN_OPEN_AI_API_KEY. Every other Captain LLM
call site got updated then; this one (used by Portal::ArticleIndexingJob
for help center article embedding search terms) didn't, so it sent a
blank bearer token unless you also happened to have the old env var set.

Also drops the stale OPENAI_API_KEY line from .env.example and points to
where the key actually lives now (Super Admin > App Configs > Captain).

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-02 19:29:50 +05:30
Sivin Varghese
90b9dba9f9 fix: correct report drilldown drawer position in RTL/LTR (#14919) 2026-07-02 18:44:25 +05:30
Sivin Varghese
2a21d075f6 fix: preserve link URLs on channels without hyperlink support (#14912) 2026-07-02 17:23:53 +05:30
Shivam Mishra
b8545019e1 feat: add catch-all failure handling to article writer (#14881)
Help-center onboarding jobs were getting stuck in the `generating` state
indefinitely. `Onboarding::HelpCenterArticleWriterJob` only finalized
the generation counter for two exception types
(`Firecrawl::FirecrawlError`, `ArticleBuildFailed`). Any other error
exhausted Sidekiq's default retries and landed in the dead set without
ever bumping `finished`, leaving state at `total - 1` / `generating`
until the 7-day Redis TTL expired. Observed in production: 4 generations
wedged at exactly `finished = total - 1`, four days after the run, with
no further progress — the onboarding UI showed "generating" the whole
time.


This PR adds a `discard_on StandardError` catch-all to
`HelpCenterArticleWriterJob`, after the existing `retry_on
Firecrawl::FirecrawlError` and `discard_on ArticleBuildFailed` handlers.
ActiveJob matches in declaration order, first match wins, so existing
behavior is unchanged — the catch-all only absorbs errors that
previously fell through to unhandled retry-then-dead-set. It routes
through the same `on_writer_failure` → `finalize` path, so state always
progresses to `completed`.

<details><summary>

##### Script to remove dead jobs

</summary>
<p>

```rb
# One-off: unstick Onboarding::HelpCenterGenerationState keys that are wedged in
# "generating" because a writer job died on an unhandled exception (neither
# FirecrawlError nor ArticleBuildFailed) and exhausted Sidekiq retries without
# ever calling record_article_finished.
#
# These portals have real articles (finished is 1 short of total). Marking them
# "completed" is the honest terminal state: generation is done, one article failed.
#
# Run on a prod box (dry-run first, then REMOVE_DRY_RUN=1):
#   RAILS_ENV=production bundle exec rails runner scripts/help_center_investigation/unstick_generating.rb
#
# To actually write, set REMOVE_DRY_RUN=1 in the environment.

pattern = format(Redis::Alfred::HELP_CENTER_GENERATION, id: '*')
dry_run = ENV['REMOVE_DRY_RUN'].blank?

stuck = []
Redis::Alfred.with do |conn|
  conn.scan_each(match: pattern, count: 1000) do |key|
    h = conn.hgetall(key)
    next unless h['status'] == 'generating'

    gen_id = key.sub('HELP_CENTER_GENERATION::', '')
    stuck << { gen_id: gen_id, total: h['total'], finished: h['finished'], key: key }

    unless dry_run
      conn.hset(key, 'status', 'completed')
      conn.expire(key, Onboarding::HelpCenterGenerationState::TTL)
    end
  end
end

puts "#{dry_run ? '[DRY RUN] ' : ''}Found #{stuck.size} stuck 'generating' states:"
stuck.each do |s|
  puts "  gen=#{s[:gen_id]} total=#{s[:total]} finished=#{s[:finished]} -> #{dry_run ? 'would mark completed' : 'marked completed'}"
end
puts
puts 'Re-run with REMOVE_DRY_RUN=1 to apply.' if dry_run
````

</p>
</details>
2026-07-02 16:17:34 +05:30
Sony Mathew
6a7ca9dd3b feat: Add report bar drilldown drawer (#14626)
## Description

Adds drilldown support for report bar charts powered by
`ReportContainer`. Clicking a non-zero report bar now opens a right-side
drawer with the conversations or messages that contributed to that
bucket, with each row linking to the underlying conversation and message
rows linking with `messageId`.

This includes a new `GET /api/v2/accounts/:account_id/reports/drilldown`
endpoint, backend drilldown builders/serializers, generic chart click
emission, local drawer state via `useReportDrilldown`, compact drilldown
cards, pagination, stale-response protection, and validation for
unsupported drilldown dimensions.

Fixes # CW-4497

https://linear.app/chatwoot/issue/CW-4497/drill-down-on-agent-conversations-report

## Type of change

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

## How Has This Been Tested?

Ran the focused backend and frontend checks for the drilldown endpoint,
builder, chart click handling, drawer/card UI, API helper, and
stale-response handling.

Here are the screenshots on how it looks like:
<img width="1792" height="1199" alt="Screenshot 2026-06-02 at 11 32
11 PM"
src="https://github.com/user-attachments/assets/6bdb8832-b9df-4bf3-9a2a-beaefe203b6e"
/>
<img width="1791" height="1230" alt="Screenshot 2026-06-02 at 11 32
34 PM"
src="https://github.com/user-attachments/assets/36e92eb7-3208-4855-87f4-0c7f316df54d"
/>
<img width="1784" height="1235" alt="Screenshot 2026-06-02 at 11 32
46 PM"
src="https://github.com/user-attachments/assets/f7a53916-74f2-4622-9305-042e0ac9e877"
/>



## 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: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-07-02 16:07:26 +05:30
Shivam Mishra
6c9efc4e92 fix: assign outbound voice call conversation to the calling agent (#14906)
Outbound voice calls were being auto-assigned to the wrong agent. When
an agent placed an outbound call, the conversation was created without
an assignee, so inboxes with auto-assignment enabled would round-robin
it to a different agent instead of keeping it with the person who
actually made the call. This made it hard to tell which agent was on an
active call.

## What changed

- Set the calling agent as the conversation's assignee when creating an
outbound voice call conversation.
- This prevents the generic auto-assignment handler from treating the
conversation as unassigned and reassigning it.
- Added specs covering the assignment, including a regression case with
inbox auto-assignment enabled.

**Note:** this applies to newly placed calls; it does not retroactively
fix conversations that were already mis-assigned.

---------

Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-07-02 16:06:11 +05:30
Sivin Varghese
34d8741b9b feat: drag to reorder help center articles across pages (#14910) 2026-07-02 15:47:01 +05:30
Sony Mathew
7bf76057c2 fix: SLA handling for blocked contacts (#14861)
# Pull Request Template

## Description

Blocked contacts are now excluded from SLA assignment, processing,
reports, and conversation SLA UI while they remain blocked. Existing SLA
records are preserved, and SLA behavior resumes if the contact is
unblocked.

Fixes
https://linear.app/chatwoot/issue/CW-7435/sla-should-not-trigger-for-blocked-contacts

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/enterprise/models/conversation_spec.rb
spec/enterprise/models/applied_sla_spec.rb
spec/enterprise/services/enterprise/action_service_spec.rb
spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb
spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb
spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/presenters/conversations/event_data_presenter_spec.rb` —
78 examples, 0 failures
- `bundle exec rubocop
enterprise/app/controllers/api/v1/accounts/applied_slas_controller.rb
enterprise/app/jobs/sla/process_account_applied_slas_job.rb
enterprise/app/models/applied_sla.rb
enterprise/app/models/enterprise/concerns/conversation.rb
enterprise/app/presenters/enterprise/conversations/event_data_presenter.rb
enterprise/app/services/enterprise/action_service.rb
enterprise/app/services/sla/evaluate_applied_sla_service.rb
lib/tasks/apply_sla.rake
spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb
spec/enterprise/models/applied_sla_spec.rb
spec/enterprise/models/conversation_spec.rb
spec/enterprise/presenters/conversations/event_data_presenter_spec.rb
spec/enterprise/services/enterprise/action_service_spec.rb
spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb` — no
offenses
- `pnpm exec vitest --no-watch --no-cache --no-coverage
app/javascript/dashboard/components/widgets/conversation/specs/ConversationCard.spec.js`
— 2 tests passed
- `pnpm exec eslint
app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue
app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue
app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue
app/javascript/dashboard/components/widgets/conversation/specs/ConversationCard.spec.js`
— passed with existing raw-text warnings in `ConversationHeader.vue`
- `git diff --cached --check` — clean

## 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>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-02 14:16:29 +05:30
Tanmay Deep Sharma
a3c7f3b204 fix: finalize WhatsApp calls when terminate webhook overtakes connect (#14836)
## Description

Some inbound WhatsApp calls stayed stuck in "ringing" forever. When a
caller hung up within ~1s of dialing, Meta delivered the terminate
webhook before the connect webhook. The terminate arrived with no call
record yet and was dropped, then connect created the call in ringing
with nothing left to close it. These calls now correctly land as missed
(no_answer), and an agent who taps Accept on a call that already ended
gets a clean "call ended" instead of a generic error.

## Type of change

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

## How Has This Been Tested?

- local UI testing

## 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
2026-07-02 13:58:36 +05:30
Pranav
49b0ab0e1f fix: Consider business hours when computing SLA breaches (#13392)
- Fixes SLA breach computation to respect the "Only during business
hours" setting
- Backend now pre-computes SLA deadlines, simplifying frontend logic

## How it works

Before: SLA deadlines were calculated using wall-clock time, ignoring
business hours.

After: When an SLA policy has "Only during business hours" enabled and
the inbox has working hours configured, the deadline is calculated by
adding threshold time only during business hours.


**How you check if a conversation has a SLA hit or miss?**

<img width="474" height="510" alt="Screenshot 2026-01-28 at 7 06 53 PM"
src="https://github.com/user-attachments/assets/54ec8581-18b8-45c6-a356-de8c778ea78d"
/>


**Example:**
- Conversation created: Friday 4:30 PM
- FRT threshold: 1 hour
- Business hours: Mon-Fri 9 AM - 5 PM

| | Breach time |
|--|--|
| Before | Friday 5:30 PM |
| After | Monday 9:30 AM |

## Test plan

- [x] Create an SLA policy with "Only during business hours" enabled
- [x] Configure inbox with business hours (e.g., Mon-Fri 9-5)
- [x] Conversation created during business hours
  - Create a conversation on Wednesday 10:00 AM UTC
- Expected: FRT deadline shows Wednesday 12:00 PM UTC (2 business hours
later)
- [x] Conversation created before business hours
  - Create a conversation on Wednesday 7:00 AM UTC
- Expected: FRT deadline shows Wednesday 11:00 AM UTC (counting starts
at 9 AM)
- [x] Conversation created after business hours
  - Create a conversation on Wednesday 6:00 PM UTC
- Expected: FRT deadline shows Thursday 11:00 AM UTC (counting starts
next day 9 AM)
- [x] Conversation created on weekend
  - Create a conversation on Saturday 10:00 AM UTC
  - Expected: FRT deadline shows Monday 11:00 AM UTC (skips weekend)
- [x] Threshold spans weekend
  - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT
- Expected: FRT deadline shows Monday 10:00 AM UTC (1h Friday + 1h
Monday)
- [x] SLA without business hours
  - Create an SLA policy with only_during_business_hours: false
  - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT
  - Expected: FRT deadline shows Friday 6:00 PM UTC (wall-clock time)
- [x] All Day marked as closed_all_day
  - Create a conversation on Tuesday 4:00 PM UTC with 2-hour FRT
  - Expected: FRT deadline shows Thursday 10:00 AM UTC
- [x] All Day marked as open_all_day
  - Create a conversation on Saturday 10:00 AM UTC with 2-hour FRT
  - Expected: FRT deadline shows Saturday 12:00 PM UTC 
- [x] UI displays correct countdown
  - Verify conversation card shows correct SLA timer
  - Verify timer shows flame icon when breached
  - Verify timer shows alarm icon when within threshold
  - Time updates automatically when time passes
- [x] Verify the breach with a different timezone than your local
timezone

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-07-02 13:33:22 +05:30
Aakash Bakhle
926a9d8a69 fix(captain): default temperature to 0.5 and remove UI control (#14879)
# Pull Request Template

## Description

- Default temperature to 0.5 and remove UI control
- No migrations needed for existing accounts, their current settings are
preserved

## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and spec

## 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-30 15:14:03 +05:30
Aakash Bakhle
9caceea858 fix(deps): update msgpack for CVE-2026-54522 (#14898)
# Pull Request Template

## Description

This updates the locked `msgpack` gem from `1.8.0` to `1.8.3` so the
bundle-audit check no longer flags CVE-2026-54522. The upgrade stays
within the existing transitive dependency constraints used by `bootsnap`
and `datadog`.

Fixes:
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114757/workflows/f8c7b37f-27d5-45d4-9f1b-1d1782ebc4e3/jobs/162250

## Type of change

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

## How Has This Been Tested?

- `eval "$(rbenv init -)" && bundle exec bundle audit update && bundle
exec bundle audit check -v`
- `eval "$(rbenv init -)" && bundle exec rspec
spec/listeners/action_cable_listener_spec.rb`
- `eval "$(rbenv init -)" && RUBOCOP_CACHE_ROOT=tmp/rubocop_cache bundle
exec rubocop --no-server Gemfile`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-30 14:56:34 +05:30
Shivam Mishra
8670f66155 feat: broaden search scope for help center generation (#14880)
**Broaden the Firecrawl `map` search term list so help-center onboarding
doesn't skip sites with non-standard docs paths**

Help-center onboarding was skipping ~70% of new accounts, and ~60% of
those skips came from a single failure: `"map returned no links"`. The
root cause was an overly narrow hardcoded search query passed to
Firecrawl's `map` endpoint.

The curator (`Onboarding::HelpCenterCurator`) calls `Firecrawl.map(url,
search: MAP_SEARCH)` to discover candidate pages before the LLM curation
step. `MAP_SEARCH` was hardcoded to `"docs help support faq"` — a 4-term
list that only matched sites whose help content sat at `/docs`, `/help`,
`/support`, or `/faq`. Sites using `/resources`, `/guides`, `/kb`,
`/articles`, `/handbook`, `/learn`, `/how-to`, `/tutorial`,
`/troubleshooting`, or a docs subdomain found nothing, so the job raised
`CurationSkipped` and left the portal empty.

Firecrawl's `search` param is a grep-style substring filter across URL,
title, and description (not a semantic query), so the fix is to broaden
the term list rather than drop it. The LLM curator downstream
(`Captain::Llm::HelpCenterCurationService`) already filters returned
links by quality — it has the full URL-path-priority prompt and a
25-article hard ceiling — so a wider crawl net is safe and doesn't
change the final article quality bar.

**What changed**

- `enterprise/app/services/onboarding/help_center_curator.rb`:
`MAP_SEARCH` broadened from `"docs help support faq"` to a 13-term list
covering common help-content path hints. Comment added documenting why
the term list exists and that the LLM curator does the real filtering.
2026-06-30 14:37:15 +05:30
Sivin Varghese
2767bd434b fix: Show a clear error when message translation fails (#14891) 2026-06-30 14:23:47 +05:30
Aakash Bakhle
ce2e10e89e fix: tighten captain v2 (#14883)
# Pull Request Template

## Description

Tightens v2 prompt and config to match v1

## Type of change

Please delete options that are not relevant.

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


## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally

## 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-30 14:20:54 +05:30
Sivin Varghese
56275b750e fix: respect companies feature flag for auto-association (#14886)
# Pull Request Template

## Description

This PR stops new contacts from getting an auto-assigned company name
when the Companies feature is disabled.

Since #14496, email-domain company auto-association also updates a
contact's `company_name`. However, the callback isn't gated behind the
Companies feature flag, so accounts without the feature enabled still
auto-create companies and overwrite any `company_name` provided via the
SDK/`setUser`.

This PR gates `should_associate_company?` behind
`account.feature_enabled?('companies')`, so auto-association only runs
when the Companies feature is enabled.

Fixes
https://linear.app/chatwoot/issue/CW-7462/setuser-overwrites-contact-company-name-for-accounts-that-dont-use

## Type of change

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


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-29 17:23:31 -07:00
Shivam Mishra
299bc6c0a4 fix: recover from stale LeadSquared lead ids on activity sync (#14818)
LeadSquared sync now recovers automatically when a contact's cached lead
has been deleted or merged on the LeadSquared side. Previously the stale
lead id was never cleared, so every new conversation or contact update
for that contact failed with "Lead not found"
(`MXInvalidEntityReferenceException`) indefinitely.

## What changed
- Activity sync: on a "Lead not found" error while posting a
conversation/transcript activity, clear the cached `leadsquared_id`,
re-resolve the contact to a fresh lead, and retry the activity once
(guarded against loops and duplicate leads).
- Contact sync: on the same error while updating an existing lead, clear
the cached id and create a fresh lead instead.
- Fix `get_lead_id` to actually return early for unidentifiable contacts
(the guard previously fell through).

## How to reproduce
1. For a LeadSquared-enabled account, point a contact's cached lead id
at a lead that no longer exists in LeadSquared.
2. Update the contact, or create/resolve a conversation for it.
3. Before: the sync fails repeatedly with "Lead not found" and never
self-corrects. After: the stale id is cleared, a fresh lead is
resolved/created, and subsequent syncs reuse the healed id.

---------

Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-06-29 16:38:08 +05:30
Aakash Bakhle
7522457740 feat: v2 - generations get trace level attributes (#14878)
# Pull Request Template

~~Note: merge only after https://github.com/chatwoot/ai-agents/pull/74
has been merged~~

## Description

Before:
<img width="436" height="617" alt="image"
src="https://github.com/user-attachments/assets/fd5be8dc-abab-4e01-b251-366648b998ea"
/>


After:
<img width="446" height="577" alt="image"
src="https://github.com/user-attachments/assets/8d96d2f2-c126-4cca-b3a2-f2a62b204adf"
/>


<img width="441" height="558" alt="image"
src="https://github.com/user-attachments/assets/89da4157-48a5-4fdc-9c67-9bf987e31638"
/>



## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and specs

## 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-29 13:50:17 +05:30
Sony Mathew
e4ef2de8c8 fix(security): Update crass to 1.0.7 (#14882)
## Description

Updates the transitive `crass` dependency from `1.0.6` to `1.0.7` so the
bundle-audit security check no longer flags the Crass denial-of-service
advisories published on June 25, 2026. `crass` is pulled in through
`rails-html-sanitizer -> loofah`, and this change only updates the
resolved lockfile version.

Fixes # N/A

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

- `bundle exec bundle audit update && bundle exec bundle audit check -v`
- `bundle exec rspec spec/mailboxes/mailbox_helper_spec.rb
spec/mailboxes/reply_mailbox_spec.rb
spec/mailboxes/imap/imap_mailbox_spec.rb
spec/models/channel/telegram_spec.rb
spec/lib/integrations/slack/send_on_slack_service_spec.rb
spec/lib/integrations/slack/update_slack_message_service_spec.rb
spec/presenters/html_parser_spec.rb`
- `bundle exec rubocop Gemfile`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-29 13:47:57 +05:30
Sojan Jose
d0b1c055e8 chore: Track cloud plan activation conversions (#14834)
## Summary
- track cloud plan activation conversions when an attributed account
moves from the configured default cloud plan to a paid plan
- use the Stripe webhook event time as the activation timestamp so the
30-day signup attribution window reflects the actual upgrade event
- send the Stripe subscription amount and currency for the conversion
value
- mark the account attribution after enqueueing so later plan updates do
not send duplicate activation conversions

## Notes
- Marketing tracker: https://linear.app/chatwoot/issue/MAR-113
- Cloud implementation: https://linear.app/chatwoot/issue/LEA-34
- Stripe billing stays responsible for subscription state and value
calculation.
- Cloud plan activation conversion tracking is handled by a small
dedicated service that owns the activation rule, duplicate marker, and
conversion enqueue.
- Website attribution cookie capture remains separate in the marketing
attribution service.
- There is no frontend change and no new user-facing configuration.
- Conversion upload still no-ops outside Chatwoot Cloud and when
attribution has no supported click identifier.
2026-06-25 18:41:43 -07:00
Sony Mathew
cf134deb37 fix: Preserve Captain LLM defaults (#14858)
# Pull Request Template

## Description

Adjusts the Captain LLM feature defaults after feature routing so the
defaults stay intentional and avoid unintended high-cost model upgrades.
Assistant, copilot, and onboarding content generation now default to
`gpt-4.1`; audio transcription keeps `gpt-4o-mini-transcribe` as the
default while exposing `whisper-1` as an available account override
option.

Related: https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

- `ruby -ryaml -e 'yaml = YAML.load_file("config/llm.yml"); features =
yaml.fetch("features"); models = yaml.fetch("models"); features.each {
|name, cfg| missing = Array(cfg["models"]) - models.keys; raise
"#{name}: missing #{missing.join(",")}" if missing.any?; raise "#{name}:
default not in models" unless
Array(cfg["models"]).include?(cfg["default"]) }'`
- `node -e
"JSON.parse(require('fs').readFileSync('app/javascript/dashboard/i18n/locale/en/settings.json',
'utf8'))"`
- `pnpm exec prettier --check
app/javascript/dashboard/i18n/locale/en/settings.json`
- `bundle exec rspec spec/lib/llm/feature_router_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/models/concerns/agentable_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/models/account_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/controllers/super_admin/accounts_controller_spec.rb`
- `bundle exec rspec
spec/enterprise/services/captain/llm/article_translation_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb`
- `RUBOCOP_CACHE_ROOT=/private/tmp/rubocop_cache bundle exec rubocop
enterprise/app/services/captain/llm/article_translation_service.rb
enterprise/app/services/messages/audio_transcription_service.rb
spec/enterprise/services/captain/llm/article_translation_service_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_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
- [ ] 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-06-25 21:35:09 +05:30
Sony Mathew
b8b62ad0f1 feat: Harden model override preferences (5/6) (#14846)
## Description

Hardens the Captain model override preferences API so account-level
overrides follow the same feature-router contract used by runtime LLM
calls. The API now permits model and feature keys from `llm.yml`,
removes blank model overrides, rejects invalid saved model combinations,
and returns each feature's effective model, provider, and source for UI
clients.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

Verified the account preferences API and account model validation
behavior for valid overrides, invalid model values, unknown feature
keys, blank override removal, and effective model/provider/source
payload metadata.

- `eval "$(rbenv init -)" && bundle exec rspec
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/models/account_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/lib/llm/feature_router_spec.rb`
- `eval "$(rbenv init -)" && bundle exec rubocop
app/controllers/api/v1/accounts/captain/preferences_controller.rb
app/models/concerns/account_settings_schema.rb
app/models/concerns/captain_featurable.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/models/account_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/lib/llm/feature_router_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
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:38:11 +05:30
Sony Mathew
4e26c5b4bb feat: Route system LLM jobs (4/6) (#14843)
## Description

Routes the remaining system-only and legacy-sensitive LLM jobs through
feature-level model configuration, while preserving system credential
usage and usage-accounting behavior. This adds dedicated defaults for
help center article generation, onboarding content generation, query
translation, transcription, and search embeddings so these flows can be
configured per account without falling back to installation-wide model
settings.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

Verified the feature routing defaults and account overrides for the
touched Captain/system LLM paths, including the legacy OpenAI
transcription and paginated FAQ services.

- `eval "$(rbenv init -)" && bundle exec rspec
spec/lib/captain/base_task_service_spec.rb spec/lib/llm/models_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/enterprise/services/captain/llm/paginated_faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/enterprise/services/onboarding/help_center_article_builder_spec.rb
spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb`
- `eval "$(rbenv init -)" && bundle exec rubocop
app/controllers/api/v1/accounts/captain/preferences_controller.rb
app/models/concerns/account_settings_schema.rb
lib/captain/base_task_service.rb
enterprise/app/services/captain/llm/article_translation_service.rb
enterprise/app/services/captain/llm/article_writer_service.rb
enterprise/app/services/captain/llm/embedding_service.rb
enterprise/app/services/captain/llm/help_center_curation_service.rb
enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
enterprise/app/services/captain/llm/translate_query_service.rb
enterprise/app/services/captain/llm/widget_tagline_service.rb
enterprise/app/services/captain/onboarding/website_analyzer_service.rb
enterprise/app/services/messages/audio_transcription_service.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/lib/captain/base_task_service_spec.rb`
- `ruby -e "require 'yaml'; config = YAML.load_file('config/llm.yml');
%w[document_faq_generation help_center_article_generation
onboarding_content_generation help_center_query_translation
audio_transcription help_center_search].each { |feature| abort(%(missing
#{feature})) unless config.dig('features', feature) }; abort('wrong
article default') unless config.dig('features',
'help_center_article_generation', 'default') == 'gpt-5.2'; puts 'llm.yml
ok'"`
- `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
- [ ] 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-06-25 17:37:45 +05:30
Sony Mathew
43e0f8a141 feat: Route Enterprise LLM services (3/6) (#14841)
# Pull Request Template

## Description

Routes Enterprise assistant, copilot, FAQ, contact memory,
action-classifier, and false-promise detector LLM paths through
feature-specific model resolution. `Llm::BaseAiService` now accepts
feature/account context and uses `Llm::FeatureRouter` when that context
is present, while retaining the installation-model fallback for
unmigrated callers. This also adds a `document_faq_generation` feature
default for generative FAQ/document content.

Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models
Depends on #14840

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/lib/llm/models_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/captain/copilot/chat_service_spec.rb
spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
passed with 112 examples, 0 failures.
- `bundle exec rspec spec/models/concerns/captain_featurable_spec.rb
spec/models/account_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/lib/llm/feature_router_spec.rb` passed with 87 examples, 0
failures.
- `bundle exec rubocop enterprise/app/services/llm/base_ai_service.rb
enterprise/app/services/captain/copilot/chat_service.rb
enterprise/app/services/captain/llm/assistant_chat_service.rb
enterprise/app/services/captain/llm/faq_generator_service.rb
enterprise/app/services/captain/llm/conversation_faq_service.rb
enterprise/app/services/captain/llm/contact_notes_service.rb
enterprise/app/services/captain/llm/contact_attributes_service.rb
enterprise/app/services/captain/llm/assistant_action_classifier_service.rb
enterprise/app/services/captain/llm/assistant_false_promise_service.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/captain/copilot/chat_service_spec.rb
spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
passed with no offenses.
- `bundle exec ruby -e "require 'yaml'; config =
YAML.load_file('config/llm.yml'); abort('missing
document_faq_generation') unless config.dig('features',
'document_faq_generation'); abort('missing default') unless
config.dig('features', 'document_faq_generation', 'default'); puts
'llm.yml ok'"` passed.
- `git diff --check` passed.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:03:07 +05:30
Sony Mathew
91d8a4e2a3 feat: Route reply box LLM tasks (2/6) (#14840)
# Pull Request Template

## Description

Routes OSS reply-box and small Captain tasks through feature-specific
LLM model resolution. Rewrite, reply suggestion, summary, follow-up, and
CSAT utility analysis now resolve through the `editor` feature; label
suggestion resolves through `label_suggestion`. Existing credentials,
account OpenAI hook behavior, and instrumentation event names remain
unchanged.

Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models
Depends on #14839

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/lib/captain/base_task_service_spec.rb
spec/lib/captain/rewrite_service_spec.rb
spec/lib/captain/reply_suggestion_service_spec.rb
spec/lib/captain/summary_service_spec.rb
spec/lib/captain/label_suggestion_service_spec.rb
spec/lib/captain/csat_utility_analysis_service_spec.rb
spec/lib/captain/follow_up_service_spec.rb` passed with 81 examples, 0
failures.
- `bundle exec rubocop lib/captain/base_task_service.rb
lib/captain/rewrite_service.rb lib/captain/reply_suggestion_service.rb
lib/captain/summary_service.rb lib/captain/label_suggestion_service.rb
lib/captain/csat_utility_analysis_service.rb
lib/captain/follow_up_service.rb
enterprise/lib/enterprise/captain/reply_suggestion_service.rb
spec/lib/captain/base_task_service_spec.rb
spec/lib/captain/rewrite_service_spec.rb
spec/lib/captain/reply_suggestion_service_spec.rb
spec/lib/captain/summary_service_spec.rb
spec/lib/captain/label_suggestion_service_spec.rb
spec/lib/captain/csat_utility_analysis_service_spec.rb
spec/lib/captain/follow_up_service_spec.rb` passed with no offenses.
- `git diff --check` passed.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:01:05 +05:30
Sony Mathew
8b977b35a8 feat: Add LLM feature router (1/6) (#14839)
## Description

Adds the foundation for feature-specific LLM model routing so Captain AI
features can resolve their effective provider/model from code defaults
and account-level overrides. This fixes the provider metadata key in
`config/llm.yml`, adds `Llm::FeatureRouter`, and routes existing
`CaptainFeaturable` model defaults through the shared resolver.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/lib/llm/models_spec.rb
spec/lib/llm/feature_router_spec.rb
spec/models/concerns/captain_featurable_spec.rb` - 23 examples, 0
failures
- `bundle exec rubocop lib/llm/models.rb lib/llm/feature_router.rb
app/models/concerns/captain_featurable.rb spec/lib/llm/models_spec.rb
spec/lib/llm/feature_router_spec.rb
spec/models/concerns/captain_featurable_spec.rb` - no offenses
- `bundle exec ruby -e "require 'yaml'; config =
YAML.load_file('config/llm.yml'); abort('missing providers') unless
config['providers']; abort('missing models') unless config['models'];
abort('missing features') unless config['features']; puts 'llm.yml ok'"`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:00:07 +05:30
Shivam Mishra
62cbeae95f feat: onboarding inboxes UI (#14565)
After entering their account details, new admins land on an **Inbox
setup** screen that shows what we've already set up for them and lets
them connect their conversation channels without leaving onboarding. It
surfaces the auto-created live chat widget (and Help Center on
Enterprise), highlights channels detected from their website, and offers
a **View all** dialog to connect any supported channel inline.

### Channel status

| Channel | How it connects | Status | PR |
|---|---|---|---|
| Live chat (Website) | Auto-created during setup | ✅ Done |
https://github.com/chatwoot/chatwoot/pull/14314 |
| WhatsApp | Meta embedded signup | ✅ Done |
https://github.com/chatwoot/chatwoot/pull/14619 |
| Facebook | Login + page picker | ✅ Done |
https://github.com/chatwoot/chatwoot/pull/14619 |
| Instagram | OAuth redirect | ✅ Done |
https://github.com/chatwoot/chatwoot/pull/14568 |
| TikTok | OAuth redirect | ✅ Done |
https://github.com/chatwoot/chatwoot/pull/14569 |
| LINE | Inline credential form | ✅ Done | — |
| Telegram | Inline credential form | ✅ Done | — |
| Gmail / Outlook | OAuth (email) | ⚠️ Disabled — coming in a follow-up
| https://github.com/chatwoot/chatwoot/pull/14567 |
| SMS / API / Voice / Other email | — | ⛔ Unavailable | — |

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-25 14:54:07 +05:30
Tanmay Deep Sharma
7c7459b734 fix(whatsapp): override webhook at phone number level (#13817)
## Description

Move WhatsApp webhook callback override from WABA level to phone number
level, allowing multiple phone numbers on the same WABA to have
independent callback URLs.

## Type of change

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

## How Has This Been Tested?

- Connect a WhatsApp Cloud inbox via embedded signup
- Verify webhook setup succeeds and messages are received
- Connect a second phone number on the same WABA — both should receive
messages independently
- Delete an inbox and verify only that phone number's override is
cleared

## Checklist:

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

---------

Co-authored-by: tds-1 <tds-1@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-25 14:49:09 +05:30
Sojan Jose
0849d2e070 chore: Track cloud signup conversions (#14852)
## Summary
- enqueue `cloud_signup` conversion tracking after website attribution
is stored on a new cloud account
- keep authenticated add-workspace requests out of
attribution/conversion tracking
- reuse the existing marketing conversion tracking service and config
already available on `develop`

## Notes
- This PR only wires the signup path.
- Billing/plan activation conversion tracking is intentionally left out
for a separate rollout.
- The conversion service still no-ops outside Chatwoot Cloud and when
stored attribution has no supported click identifier.
2026-06-24 20:04:42 -07:00
Sojan Jose
cd1a214d98 chore: Add marketing conversion tracking foundation (#14851)
## Summary

Adds the minimal foundation for Cloud marketing conversion tracking:

- locked internal installation config for conversion tracking
credentials and event mappings
- generic background job and service for uploading conversion events
from stored attribution
- focused coverage for Cloud gating, click-id selection, payload shape,
and optional conversion value

This PR intentionally does not wire signup or plan activation yet. The
next step is to validate the service from Rails console against existing
attributed accounts, then add the event hooks in a follow-up PR.

## Notes

The config remains locked and is surfaced under the Internal settings
group next to the existing Cloud plan configuration. The service assumes
the locked config is present and valid; config mistakes should surface
instead of being silently ignored.
2026-06-24 18:27:02 -07:00
Pranav
f9cc702030 fix(help-center): documentation layout on custom domain root, locale (#14850)
Help centers using the documentation layout now render correctly in two
cases that previously fell back to the wrong output. Opening a portal at
its custom-domain root (e.g. docs.example.com/ ) now shows the full
documentation home
in place instead of the classic layout, and portals whose locale is a
region variant that Chatwoot doesn't ship a
translation for (e.g. th_TH , fr_ML ) now show their categories in the
sidebar on article pages.

Closes
https://linear.app/chatwoot/issue/CW-7437/portal-layout-is-not-properly-working-in-custom-domain

## How to test

1. Create a portal with the documentation layout and a custom domain
(e.g. example.chat.test ), with at least one
category containing a published article.
2. Visit the custom-domain root ( http://example.chat.test:3000/ ) → it
should show the full documentation home
(sidebar, topbar), not the classic layout, with no redirect.
3. Set the portal's locale to a region variant Chatwoot doesn't
translate, e.g. th_TH , with categories/articles
under that locale.
4. Open an article → the sidebar should list the article's category and
sibling articles. Before the fix the sidebar
was empty for these locales.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:12:55 -07:00
Muhsin Keloth
74db16158d feat: migrate dyte integration to cloudflare realtimekit (#14752)
Dyte is sunsetting its existing infrastructure after the Cloudflare
acquisition, so this migrates Chatwoot’s video call integration to
Cloudflare RealtimeKit.

The integration now uses Cloudflare Account ID, RealtimeKit App ID, and
a Cloudflare API token with Realtime Admin permissions. Meeting creation
and participant token generation now call Cloudflare’s RealtimeKit APIs,
while the existing Chatwoot call experience remains unchanged for agents
and customers.

This also adds setup-time credential validation, so admins get clearer
errors when the API token is invalid, the Cloudflare account or
permissions are incorrect, or the RealtimeKit App ID does not belong to
the selected account.

Fixes
https://linear.app/chatwoot/issue/PLA-176/migrate-dyte-integration-to-cloudflare-realtimekit

**How to test**

1. Go to Settings → Integrations → Cloudflare RealtimeKit.
2. Add a Cloudflare Account ID, RealtimeKit App ID, and API token with
Realtime Admin permissions.
3. Confirm the integration saves successfully with valid credentials.
4. Try invalid credentials and confirm the error identifies whether the
token, account/permissions, or app ID is wrong.
5. Start a video call from a conversation and confirm the RealtimeKit
meeting opens.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-24 11:04:08 +04:00
Sivin Varghese
e8edc9ebf5 feat: allow users to set an icon or emoji for teams (#14767) 2026-06-23 20:53:31 +05:30
Vishnu Narayanan
e785620921 feat: read structured X-Chatwoot-* headers for mobile session metadata (#14762)
Phase 2 of [INF-75](https://linear.app/chatwoot/issue/INF-75). Follows
up on [#14753](https://github.com/chatwoot/chatwoot/pull/14753) (Phase 1
UA pattern fallback, already merged).

When the request carries an \`X-Chatwoot-Client-Name\` header,
\`UserSessionTrackingService\` now prefers the five structured
\`X-Chatwoot-*\` headers over the User-Agent for populating the
\`user_sessions\` row:

| Header | Mapped column |
|---|---|
| \`X-Chatwoot-Client-Name\` | \`browser_name\` |
| \`X-Chatwoot-Client-Version\` | \`browser_version\` |
| \`X-Chatwoot-Device-Model\` | \`platform_name\` |
| \`X-Chatwoot-Platform-Version\` | \`platform_version\` |
| \`X-Chatwoot-Platform\` (+ model) | \`device_name\` (\`iPhone\` /
\`iPad\` / \`Android\`) |

When the header is absent or blank, the existing \`Browser.new\` path
runs, with the Phase 1 legacy UA fallback still acting as the floor.
Real browsers are untouched.

A follow-up PR in
[chatwoot-mobile-app](https://github.com/chatwoot/chatwoot-mobile-app)
will start sending these headers from the React Native build. Until that
ships, this PR is a no-op for production traffic, so it can land
independently.

Fixes INF-75.
2026-06-23 20:08:27 +05:30
Aakash Bakhle
0af9900be2 fix: model for false promise harness (#14824)
fixes model for false promise harness
2026-06-23 18:42:34 +05:30
Aakash Bakhle
84fda8a323 fix(captain): move false promise harness setting to account (#14823)
# Pull Request Template

## Description

moves the harness to account settings rather than assistant settings

## Type of change
refactor

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally and specs

## 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-23 16:55:42 +05:30
Aakash Bakhle
1c7e60880d fix: add harness on false promises (#14672)
# Pull Request Template

## Description


https://linear.app/chatwoot/issue/AI-179/false-promise-soft-handoff-guard

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

Against negative cases identified by evals

## 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
2026-06-23 16:30:14 +05:30
Aakash Bakhle
8e27ca4282 fix(firecrawl): get url in case sourceUrl is not available (#14820)
# Pull Request Template

## Description

Fixes inconsistency by adding a fallback to use `url` instead of
`sourceUrl` when Firecrawl returns empty `sourceUrl` in production.

We switched to v2 endpoints of Firecrawl in
https://github.com/chatwoot/chatwoot/pull/14624 and it worked locally,
but seems to fail for some cases in production.

## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally


## 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-23 13:40:31 +05:30
Muhsin Keloth
ee5e20551d fix(facebook): handle messenger post attachment type (#14813)
Facebook Messenger messages that share a post no longer break message
ingestion. Following the recent sticker change, Meta now sends a shared
post as a new `post` attachment type. Chatwoot didn't recognise `post`
as a valid attachment file type, so the webhook job crashed and the
message — and any others in the same batch — failed to sync. Shared
posts now appear in the conversation as a fallback link (title + URL),
the same way `share` attachments already do.

This is the same class of bug as #14793 (sticker), just a different
attachment type.

#### Root cause

`post` was neither skipped as unsupported nor mapped to a valid
`file_type`, so `attachment_params` produced `file_type: :post`. The
`Attachment#file_type` enum has no `post` value, so it raised
`ArgumentError: 'post' is not a valid file_type`, crashing
`Webhooks::FacebookEventsJob`.

#### Fix

The Facebook builder already maps `share -> :fallback` because shared
posts point to facebook.com page URLs rather than downloadable media. A
`post` is the same kind of attachment, so map both `share` and `post` to
`fallback` (stores the title/link, no download attempt). Also read the
fallback title from `payload.title`, since the post payload nests it
there rather than at the top level.

## How to reproduce

1. Connect a Facebook Page inbox.
2. Share a Facebook post into Messenger to that page.
3. Before this change: `Webhooks::FacebookEventsJob` raises
`ArgumentError: 'post' is not a valid file_type` and the message is
dropped.
4. After this change: the shared post shows up in the conversation as a
fallback link.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:04 +04:00
Tanmay Deep Sharma
df79c0bbde feat: exclude stale conversations via assignment policy age threshold (#14766)
## Linear ticket
-
https://linear.app/chatwoot/issue/CW-7137/assignment-v2-backlog-flush-overloads-agents

## Description

Assignment policies now skip stale unassigned conversations
automatically. Each policy carries an age threshold (defaults to 7
days), so auto-assignment only picks up recent backlog instead of
draining very old, forgotten conversations. The threshold is
configurable per policy and can be cleared to assign conversations
regardless of age. Previously this control existed only on Enterprise
capacity policies (`exclude_older_than_hours`); it now lives on the
assignment policy itself, so every V2 inbox benefits without needing a
capacity policy.

## Type of change

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

## How Has This Been Tested?

- Local UI flows

## Screenshots? 

<img width="645" height="822" alt="image"
src="https://github.com/user-attachments/assets/ec58db86-c1fa-4f9e-be87-2e24ff02e077"
/>


## 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>
2026-06-23 12:22:45 +05:30
Tanmay Deep Sharma
647cfc2d83 feat: distinguish agent-declined calls with a rejected status (#14783)
## Linear ticket
-
https://linear.app/chatwoot/issue/CW-7374/agent-declined-voice-calls-miscounted-as-failed

## Description

Agent rejections were stored with status: failed, so call reports lumped
deliberate declines together with real technical failure.

Fix: give declines their own terminal rejected status (Twilio + WhatsApp
paths), keeping end_reason: agent_rejected. Genuine provider/network
failures stay failed. Frontend renders declines exactly as before;
existing rows backfilled via migration.

## Type of change

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

## How Has This Been Tested?

- Tested on 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: Sony Mathew <sony@chatwoot.com>
2026-06-23 12:22:29 +05:30
Sivin Varghese
961ec86ba9 chore: support custom variables in auto-resolution message (#14782)
# Pull Request Template

## Description

This PR adds support for custom variables in auto-resolution messages
within Conversation Workflows.

Agents can insert variables (e.g. `{{contact.name}}`) into the custom
auto-resolution message using the variable picker by typing `{{`. These
variables are resolved with actual conversation data when the
auto-resolution message is sent.

The field uses the existing editor with the formatting toolbar hidden,
since auto-resolution messages are delivered across all channels,
including SMS, where formatting isn't supported. Existing message
content is preserved as-is, and typing `{{` opens the variable picker
for quick insertion.

Fixes
https://linear.app/chatwoot/issue/CW-7369/support-custom-variables-in-conversations-workflow-text-fields

## Type of change

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

## How Has This Been Tested?

### Screenshots
<img width="1262" height="764" alt="image"
src="https://github.com/user-attachments/assets/e43be22c-f86e-436a-9fdd-5bcb73c61ca3"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-23 11:35:52 +05:30
4501 changed files with 277264 additions and 29271 deletions

View File

@@ -21,3 +21,17 @@ ignore:
- CVE-2026-33176
- CVE-2026-33195
- CVE-2026-33202
# No Rails 7.1 patch for this Active Storage advisory; mitigated locally.
# Remove once on Rails 7.2.3.1+.
- CVE-2026-66066
# RubyLLM's name-normalization ReDoS is reported on Ruby 3.1; we require Ruby 3.4.4.
# Built-in tool names are fixed; custom tools use bounded slugs and override normalization.
# Patched 2.0 releases need an ai-agents migration (currently constrained to RubyLLM 1.x).
# Remove when a supported patched RubyLLM/ai-agents combination is adopted.
- CVE-2026-67991
# Think-tag parsing and Mistral matching ReDoS are reported on Ruby 3.1.
# We pin Ruby 3.4.4, where the affected regexes are linear-time.
# Remove when a supported patched RubyLLM/ai-agents combination is adopted;
# reassess if the supported Ruby runtime changes.
- CVE-2026-67987
- CVE-2026-67989

View File

@@ -77,7 +77,8 @@ jobs:
- node/install:
node-version: '24.13'
- node/install-pnpm
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -118,7 +119,8 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -144,12 +146,13 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 20
parallelism: 18
steps:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i

View File

@@ -15,6 +15,15 @@ SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000
# Enterprise conversation monitors (enable the conversation_monitors account feature separately)
# Set CAPTAIN_OPENROUTER_API_KEY and optional CAPTAIN_OPENROUTER_DECISION_MODEL_ENDPOINT
# in Super Admin -> Settings -> Captain (shared installation settings). The endpoint is the API base URL.
# CONVERSATION_MONITORS_MODEL=typesafe/jev-1.13
# CONVERSATION_MONITORS_LIMIT=20
# Allowlisted contact/conversation custom attribute keys to send as customer context
# CONVERSATION_MONITORS_ATTRIBUTES=deployment_type
# Each account can make 500,000 monitor calls per UTC day and 10,000,000 per UTC month.
# To use a dedicated URL for help center pages
# HELPCENTER_URL=http://0.0.0.0:3000
@@ -59,6 +68,16 @@ REDIS_SENTINEL_MASTER_NAME=
# ref: https://github.com/chatwoot/chatwoot/issues/2420
# REDIS_OPENSSL_VERIFY_MODE=none
# OpenSearch / Elasticsearch-compatible search config
# Set OPENSEARCH_URL to enable advanced search. Elastic Cloud API keys can be configured with either key name below.
# API key values must be provided without the ApiKey authorization scheme prefix.
# OPENSEARCH_URL=
# OPENSEARCH_API_KEY=
# ELASTICSEARCH_API_KEY=
# OPENSEARCH_AWS_ACCESS_KEY_ID=
# OPENSEARCH_AWS_SECRET_ACCESS_KEY=
# OPENSEARCH_AWS_REGION=us-east-1
# Postgres Database config variables
# You can leave POSTGRES_DATABASE blank. The default name of
# the database in the production environment is chatwoot_production
@@ -70,6 +89,13 @@ RAILS_ENV=development
# Changes the Postgres query timeout limit. The default is 14 seconds. Modify only when required.
# POSTGRES_STATEMENT_TIMEOUT=14s
RAILS_MAX_THREADS=5
# Number of Puma worker processes (clustered mode). Defaults to 0 (single
# process) when unset. On a multi-core production server, set this to run Puma
# in clustered mode so CPU-bound requests don't serialize behind Ruby's GVL.
# A common starting point is the number of available CPU cores; total
# concurrency is roughly WEB_CONCURRENCY * RAILS_MAX_THREADS, so size it
# against available RAM. See config/puma.rb.
# WEB_CONCURRENCY=2
# The email from which all outgoing emails are sent
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
@@ -132,6 +158,11 @@ ACTION_MAILBOX_SES_SNS_TOPIC=
# Storage
ACTIVE_STORAGE_SERVICE=local
# Restrict libvips to its trusted, well-tested image loaders when Active Storage
# generates variants. Hardens image processing against untrusted uploads.
# Requires libvips >= 8.13 (ignored on older versions).
VIPS_BLOCK_UNTRUSTED=1
# Amazon S3
# documentation: https://www.chatwoot.com/docs/configuring-s3-bucket-as-cloud-storage
S3_BUCKET_NAME=
@@ -151,6 +182,8 @@ LOG_SIZE=500
# Facebook
# documentation: https://www.chatwoot.com/docs/facebook-setup
# Connected Facebook pages are subscribed by Chatwoot to Messenger webhook
# fields, including postbacks used by Get Started and button flows.
FB_VERIFY_TOKEN=
FB_APP_SECRET=
FB_APP_ID=
@@ -168,6 +201,7 @@ TWITTER_ENVIRONMENT=
#slack integration
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
SLACK_SIGNING_SECRET=
# Google OAuth
GOOGLE_OAUTH_CLIENT_ID=
@@ -230,7 +264,23 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=true
# RACK_ATTACK_LIMIT=300
## Parent switch for all widget throttles; per-endpoint switches below only apply when this is on
# ENABLE_RACK_ATTACK_WIDGET_API=true
## Widget conversation creates, per minute, keyed on (IP, website_token)
# ENABLE_RACK_ATTACK_WIDGET_CONVERSATIONS=true
# RATE_LIMIT_WIDGET_CONVERSATIONS=30
## Widget message creates, per minute, keyed on (IP, website_token)
# ENABLE_RACK_ATTACK_WIDGET_MESSAGES=true
# RATE_LIMIT_WIDGET_MESSAGES=60
## Widget contact updates, per hour, per IP
# ENABLE_RACK_ATTACK_WIDGET_CONTACTS=true
# RATE_LIMIT_WIDGET_CONTACTS=60
## Widget page loads (new session), per hour, per IP
# ENABLE_RACK_ATTACK_WIDGET_LOAD=true
# RATE_LIMIT_WIDGET_LOAD=200
## Widget transcript requests, per hour, per IP
# ENABLE_RACK_ATTACK_WIDGET_TRANSCRIPT=true
# RATE_LIMIT_WIDGET_TRANSCRIPT=5
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
@@ -271,10 +321,14 @@ AZURE_APP_SECRET=
# Enable verbose logging each time a job is dequeued in Sidekiq
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# Sidekiq health check server (defaults to 0.0.0.0:7433).
# Uncomment SIDEKIQ_ALIVE_HOST to restrict the health check to localhost.
# SIDEKIQ_ALIVE_HOST=127.0.0.1
# SIDEKIQ_ALIVE_PORT=7433
# AI powered features
## OpenAI key
# OPENAI_API_KEY=
# AI powered features (Captain)
# The OpenAI API key and endpoint for Captain are not configured via .env.
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT).
# Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes
@@ -283,3 +337,15 @@ AZURE_APP_SECRET=
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
# Publish Sidekiq queue metrics to AWS CloudWatch for autoscaling. Off by default.
# By default uses the instance role, which must allow cloudwatch:PutMetricData.
# ENABLE_SIDEKIQ_CLOUDWATCH=true
# SIDEKIQ_CLOUDWATCH_NAMESPACE=Chatwoot/Sidekiq
# Publish interval in seconds (default 60; values below 60 work but bill as high-resolution CloudWatch metrics)
# SIDEKIQ_CLOUDWATCH_INTERVAL=60
# Optional: dedicated credentials for CloudWatch only, separate from the S3 (storage) keys.
# Set these to override the instance role when a role is not available.
# SIDEKIQ_CLOUDWATCH_AWS_ACCESS_KEY_ID=
# SIDEKIQ_CLOUDWATCH_AWS_SECRET_ACCESS_KEY=
# SIDEKIQ_CLOUDWATCH_AWS_REGION=us-east-1

View File

@@ -247,6 +247,7 @@ module.exports = {
},
env: {
browser: true,
es2020: true,
node: true,
},
globals: {

View File

@@ -106,6 +106,19 @@ def build_description(adv: dict[str, Any]) -> str:
)
def post_slack(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None:
severity = adv.get("severity") or "unknown"
title = f"[{adv['ghsa_id']}] {adv['summary']}"[:200]
text = (
f"*<{issue['url']}|{title}>*\n"
f"Linear: {issue['identifier']} · Severity: {severity} (CVSS {cvss_score(adv)}) · <{adv['html_url']}|advisory>"
)
try:
requests.post(webhook_url, json={"text": text}, timeout=10)
except requests.RequestException:
pass
def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None:
severity = adv.get("severity") or "unknown"
title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250]
@@ -146,6 +159,7 @@ def main() -> int:
project_id = required_env("LINEAR_PROJECT_ID")
label_id = required_env("LINEAR_LABEL_ID")
discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None
slack_webhook = os.environ.get("SLACK_WEBHOOK_URL") or None
advisories = fetch_triage_advisories(repo, gh_token)
print(f"Fetched {len(advisories)} triage advisories")
@@ -186,6 +200,8 @@ def main() -> int:
created += 1
if discord_webhook:
post_discord(adv, issue, discord_webhook)
if slack_webhook:
post_slack(adv, issue, slack_webhook)
print(f"Created {created}, skipped {skipped}, failed {failed}")
return 1 if failed > 0 else 0

View File

@@ -26,4 +26,5 @@ jobs:
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: python3 .github/scripts/ghsa_linear_sync.py

View File

@@ -0,0 +1,41 @@
name: Migration Version Check
on:
pull_request:
paths:
- 'db/migrate/**'
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
migration_version_check:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Reject hand-written migration versions
run: |
rounded=$(git diff --name-only --diff-filter=AR HEAD^1 HEAD -- db/migrate | grep -E '^db/migrate/[0-9]{10}0000_' || true)
if [ -n "$rounded" ]; then
echo "::error::Rounded migration versions collide across branches. Regenerate with 'bundle exec rails g migration':"
echo "$rounded"
exit 1
fi
- name: Reject duplicate migration versions
run: |
duplicates=$(ls db/migrate | cut -d_ -f1 | sort | uniq -d)
if [ -n "$duplicates" ]; then
echo "::error::Duplicate migration versions break db:migrate:"
echo "$duplicates"
exit 1
fi

View File

@@ -21,6 +21,24 @@ jobs:
- name: Run Rubocop
run: bundle exec rubocop --parallel
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Run Brakeman
# Non-blocking for now: Brakeman surfaces 35 pre-existing findings
# (13 High confidence) that need security-team triage before this
# can be turned into a hard gate.
continue-on-error: true
run: bundle exec brakeman -q --no-pager
- name: Run bundle-audit
run: |
bundle exec bundle-audit update
bundle exec bundle-audit check
lint-frontend:
runs-on: ubuntu-latest
steps:

View File

@@ -67,9 +67,20 @@ jobs:
run: |
bundle exec rspec \
spec/services/mfa/token_service_spec.rb \
spec/services/mfa/setup_token_service_spec.rb \
spec/services/mfa/authentication_service_spec.rb \
spec/requests/api/v1/profile/mfa_controller_spec.rb \
spec/controllers/api/v1/profiles_controller_spec.rb \
spec/controllers/api/v1/notification_subscriptions_controller_spec.rb \
spec/requests/api/v1/profile/sessions_controller_spec.rb \
spec/controllers/devise_overrides/sessions_controller_spec.rb \
spec/requests/devise_overrides/passwords_controller_spec.rb \
spec/requests/devise_overrides/confirmations_controller_spec.rb \
spec/controllers/api/v1/accounts_controller_spec.rb \
spec/controllers/api/v2/accounts_controller_spec.rb \
spec/requests/api/v1/accounts/base_controller_spec.rb \
spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb \
spec/enterprise/controllers/enterprise/devise_overrides/device_verification_spec.rb \
spec/models/application_record_external_credentials_encryption_spec.rb \
--profile=10 \
--format documentation
@@ -87,6 +98,13 @@ jobs:
-e "backup code" \
--profile=10 \
--format documentation
- name: Run MFA-related tests in account_spec
run: |
bundle exec rspec spec/models/account_spec.rb \
-e "enforce_mfa" \
--profile=10 \
--format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider

1
.gitignore vendored
View File

@@ -104,3 +104,4 @@ CLAUDE.local.md
.pnpm-store/*
local/
Procfile.worktree
.nodeterm/project.json

View File

@@ -16,8 +16,10 @@ Layout/LineLength:
Metrics/ClassLength:
Max: 175
Exclude:
- 'app/builders/messages/message_builder.rb'
- 'app/models/message.rb'
- 'app/models/conversation.rb'
- 'config/initializers/rack_attack.rb'
Metrics/MethodLength:
Max: 19

View File

@@ -43,13 +43,23 @@
## General Guidelines
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- **Database foreign keys**: Prefer Rails associations and callbacks over database foreign-key constraints. Add a database foreign key only when there is a clear need and after checking deletion behavior and production migration/locking risks. Existing foreign keys alone are not a reason to add new ones.
- **Migrations**: Create migrations with `bundle exec rails g migration <Name>` so the version is a real timestamp. Never hand-write or round the version (e.g. `20260925000000`); rounded versions collide across branches and break `db:migrate`.
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- Enforce eligibility and exclusivity rules at the earliest shared entry point. Do not repeat backup guards across downstream jobs, callbacks, services, or writes unless a proven independent path bypasses that point.
- Validate request parameters at the controller or request boundary, reusing existing errors so invalid input returns `422 Unprocessable Entity` instead of reaching models or Sentry.
- Accept only the documented type, shape, and value. Do not add compatibility coercions for malformed client values; fix official clients instead.
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
- Remove dead/unreachable/unused code
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
@@ -80,8 +90,10 @@
## Project-Specific
- **Translations**:
- Only update `en.yml` and `en.json`
- Other languages are handled by the community
- For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community
- Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files
- Preserve product and brand names, OAuth scopes, API values, and other machine-readable identifiers unless an official localized form exists
- When reviewing Crowdin syncs, verify protected terms remain unchanged. Add newly introduced product names, brand names, and machine-readable identifiers to the Crowdin glossary as non-translatable, and keep the glossary current
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
- **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated)
@@ -90,6 +102,15 @@
- Use compact `module/class` definitions; avoid nested styles
## Frontend Conventions
- Prefer existing design-system utilities and shared composables.
- Use typography utilities instead of manually recreating font styles.
- Use logical Tailwind utilities (`ms`, `me`, `start`, `end`) for direction-aware layouts.
- Use `rem` for arbitrary CSS dimensions; preserve native numeric values required by chart/SVG APIs.
- Extract repeated or domain-specific strings, thresholds, colors, and durations into named constants.
- Use shared request-cancellation utilities instead of local `AbortController` logic.
## Enterprise Edition Notes
- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code.

20
Gemfile
View File

@@ -3,8 +3,10 @@ source 'https://rubygems.org'
ruby '3.4.4'
##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '~> 7.1'
gem 'rails', '7.2.3.1'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -52,9 +54,8 @@ gem 'csv-safe'
##-- for active storage --##
gem 'aws-sdk-s3', require: false
# original gem isn't maintained actively
# we wanted updated version of faraday which is a dependency for slack-ruby-client
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
gem 'aws-sdk-sesv2', require: false
gem 'azure-blob', require: false
gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing'
@@ -83,7 +84,8 @@ gem 'barnes'
##--- gems for authentication & authorization ---##
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
# 2.2.3 requires Devise 5; Chatwoot remains on Devise 4 for this upgrade stack.
gem 'devise-secure_password', '2.2.1'
gem 'devise_token_auth', '>= 1.2.3'
gem 'rails-i18n', '~> 7.0'
# two-factor authentication
@@ -95,7 +97,7 @@ gem 'pundit'
# super admin
gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.3'
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
gem 'administrate-field-belongs_to_search', '>= 0.10.0'
##--- gems for pubsub service ---##
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
@@ -133,11 +135,13 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
gem 'sidekiq', '~> 7.3.10'
# We want cron jobs
gem 'sidekiq-cron', '>= 2.4.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
# publishes Sidekiq queue metrics to CloudWatch for autoscaling (opt-in via ENABLE_SIDEKIQ_CLOUDWATCH)
gem 'speedshop-cloudwatch', '~> 0.2.1', require: false
##-- Push notification service --##
gem 'fcm'
@@ -195,7 +199,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.10.0'
gem 'ai-agents', '>= 0.12.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'

View File

@@ -1,110 +1,82 @@
GIT
remote: https://github.com/chatwoot/azure-storage-ruby
revision: 9957cf899d33a285b5dfe15bdb875292398e392b
branch: chatwoot
specs:
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
nokogiri (~> 1, >= 1.10.8)
azure-storage-common (2.0.4)
faraday (~> 2.0)
faraday-follow_redirects (~> 0.3.0)
faraday-net_http_persistent (~> 2.0)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
GIT
remote: https://github.com/chatwoot/devise-secure_password
revision: adcc85fe1babfe40feae73dbcae64d14fff86e69
branch: chatwoot
specs:
devise-secure_password (2.0.1)
devise (>= 4.0.0, < 5.0.0)
railties (>= 5.0.0, < 8.0.0)
GEM
remote: https://rubygems.org/
specs:
actioncable (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
actioncable (7.2.3.1)
actionpack (= 7.2.3.1)
activesupport (= 7.2.3.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.1.5.2)
actionpack (= 7.1.5.2)
activejob (= 7.1.5.2)
activerecord (= 7.1.5.2)
activestorage (= 7.1.5.2)
activesupport (= 7.1.5.2)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.1.5.2)
actionpack (= 7.1.5.2)
actionview (= 7.1.5.2)
activejob (= 7.1.5.2)
activesupport (= 7.1.5.2)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
actionmailbox (7.2.3.1)
actionpack (= 7.2.3.1)
activejob (= 7.2.3.1)
activerecord (= 7.2.3.1)
activestorage (= 7.2.3.1)
activesupport (= 7.2.3.1)
mail (>= 2.8.0)
actionmailer (7.2.3.1)
actionpack (= 7.2.3.1)
actionview (= 7.2.3.1)
activejob (= 7.2.3.1)
activesupport (= 7.2.3.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.1.5.2)
actionview (= 7.1.5.2)
activesupport (= 7.1.5.2)
actionpack (7.2.3.1)
actionview (= 7.2.3.1)
activesupport (= 7.2.3.1)
cgi
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack (>= 2.2.4, < 3.3)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actiontext (7.1.5.2)
actionpack (= 7.1.5.2)
activerecord (= 7.1.5.2)
activestorage (= 7.1.5.2)
activesupport (= 7.1.5.2)
useragent (~> 0.16)
actiontext (7.2.3.1)
actionpack (= 7.2.3.1)
activerecord (= 7.2.3.1)
activestorage (= 7.2.3.1)
activesupport (= 7.2.3.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.1.5.2)
activesupport (= 7.1.5.2)
actionview (7.2.3.1)
activesupport (= 7.2.3.1)
builder (~> 3.1)
cgi
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
active_record_query_trace (1.8)
activejob (7.1.5.2)
activesupport (= 7.1.5.2)
activejob (7.2.3.1)
activesupport (= 7.2.3.1)
globalid (>= 0.3.6)
activemodel (7.1.5.2)
activesupport (= 7.1.5.2)
activerecord (7.1.5.2)
activemodel (= 7.1.5.2)
activesupport (= 7.1.5.2)
activemodel (7.2.3.1)
activesupport (= 7.2.3.1)
activerecord (7.2.3.1)
activemodel (= 7.2.3.1)
activesupport (= 7.2.3.1)
timeout (>= 0.4.0)
activerecord-import (2.1.0)
activerecord (>= 4.2)
activestorage (7.1.5.2)
actionpack (= 7.1.5.2)
activejob (= 7.1.5.2)
activerecord (= 7.1.5.2)
activesupport (= 7.1.5.2)
activestorage (7.2.3.1)
actionpack (= 7.2.3.1)
activejob (= 7.2.3.1)
activerecord (= 7.2.3.1)
activesupport (= 7.2.3.1)
marcel (~> 1.0)
activesupport (7.1.5.2)
activesupport (7.2.3.1)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
minitest (>= 5.1, < 6)
securerandom (>= 0.3)
tzinfo (~> 2.0)
tzinfo (~> 2.0, >= 2.0.5)
acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0)
@@ -121,12 +93,12 @@ GEM
administrate-field-active_storage (1.0.3)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.9.0)
administrate-field-belongs_to_search (0.10.0)
administrate (>= 0.3, < 1.0)
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
rails (>= 4.2, < 8.0)
selectize-rails (~> 0.6)
ai-agents (0.10.0)
ai-agents (0.12.0)
ruby_llm (~> 1.14)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@@ -144,6 +116,9 @@ GEM
aws-sdk-sns (~> 1, >= 1.61.0)
aws-eventstream (1.4.0)
aws-partitions (1.1198.0)
aws-sdk-cloudwatch (1.126.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@@ -159,22 +134,29 @@ GEM
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sdk-sesv2 (1.92.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-sns (1.70.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
azure-blob (0.8.0)
cgi
rexml
barnes (0.0.9)
multi_json (~> 1)
statsd-ruby (~> 1.1)
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
benchmark (0.5.0)
bigdecimal (4.1.2)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
brakeman (5.4.1)
brakeman (8.0.5)
racc
browser (5.3.1)
builder (3.3.0)
bullet (8.0.7)
@@ -186,19 +168,19 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
cgi (0.5.1)
cgi (0.5.2)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.7)
commonmarker (0.23.12)
concurrent-ruby (1.3.8)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
crass (1.0.7)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
@@ -211,17 +193,18 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
datadog (2.19.0)
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
libdatadog (~> 18.1.0.1.0)
libddwaf (~> 1.24.1.0.3)
datadog (2.38.0)
cgi
datadog-ruby_core_source (~> 3.5, >= 3.5.3)
libdatadog (~> 36.0.0.1.0)
libddwaf (~> 1.30.0.0.0)
logger
msgpack
datadog-ruby_core_source (3.4.1)
datadog-ruby_core_source (3.5.3)
date (3.5.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
devise (4.9.4)
bcrypt (~> 3.0)
@@ -229,6 +212,9 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-secure_password (2.2.1)
devise (>= 4.0.0, < 5.0.0)
railties (>= 5.0.0, < 9.0.0)
devise-two-factor (6.1.0)
activesupport (>= 7.0, < 8.1)
devise (~> 4.0)
@@ -289,7 +275,8 @@ GEM
ruby2_keywords
email-provider-info (0.0.1)
email_reply_trimmer (0.1.13)
erubi (1.13.0)
erb (6.0.6)
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
event_stream_parser (1.0.0)
@@ -308,8 +295,6 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-mashify (1.0.0)
faraday (~> 2.0)
hashie
@@ -317,9 +302,6 @@ GEM
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.4.0)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
@@ -431,8 +413,8 @@ GEM
googleapis-common-protos-types (~> 1.0)
gserver (0.0.1)
haikunator (1.1.1)
hairtrigger (1.0.0)
activerecord (>= 6.0, < 8)
hairtrigger (1.3.1)
activerecord (>= 6.0, < 9)
ruby2ruby (~> 2.4)
ruby_parser (~> 3.10)
hana (1.3.7)
@@ -456,19 +438,22 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.7)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
io-console (0.6.0)
irb (1.7.2)
reline (>= 0.3.6)
io-console (0.8.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
iso-639 (0.3.8)
csv
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jbuilder (2.15.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
jmespath (1.6.2)
jquery-rails (4.6.0)
rails-dom-testing (>= 1, < 3)
@@ -522,15 +507,16 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
libdatadog (18.1.0.1.0)
libdatadog (18.1.0.1.0-x86_64-linux)
libddwaf (1.24.1.0.3)
libdatadog (36.0.0.1.0)
libdatadog (36.0.0.1.0-arm64-darwin)
libdatadog (36.0.0.1.0-x86_64-linux)
libddwaf (1.30.0.0.2)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-arm64-darwin)
libddwaf (1.30.0.0.2-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-darwin)
libddwaf (1.30.0.0.2-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-linux)
libddwaf (1.30.0.0.2-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
@@ -547,10 +533,11 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.23.1)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
@@ -567,10 +554,10 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
minitest (5.27.0)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.8.0)
msgpack (1.8.3)
multi_json (1.15.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
@@ -580,8 +567,6 @@ GEM
activerecord (>= 5.2)
net-http (0.9.1)
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.6.4.1)
date
net-protocol
@@ -684,7 +669,10 @@ GEM
activerecord (>= 5.2)
activesupport (>= 5.2)
pgvector (0.1.1)
prism (1.4.0)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
procore-sift (1.0.0)
activerecord (>= 6.1)
pry (0.14.2)
@@ -706,14 +694,14 @@ GEM
rack (< 4)
rack-cors (2.0.0)
rack (>= 2.0.0)
rack-mini-profiler (3.2.0)
rack-mini-profiler (4.0.1)
rack (>= 1.2.0)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-proxy (0.7.7)
rack
rack-proxy (1.0.3)
rack (>= 2.0, < 4)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -722,43 +710,54 @@ GEM
rack-timeout (0.6.3)
rackup (2.2.1)
rack (>= 3)
rails (7.1.5.2)
actioncable (= 7.1.5.2)
actionmailbox (= 7.1.5.2)
actionmailer (= 7.1.5.2)
actionpack (= 7.1.5.2)
actiontext (= 7.1.5.2)
actionview (= 7.1.5.2)
activejob (= 7.1.5.2)
activemodel (= 7.1.5.2)
activerecord (= 7.1.5.2)
activestorage (= 7.1.5.2)
activesupport (= 7.1.5.2)
rails (7.2.3.1)
actioncable (= 7.2.3.1)
actionmailbox (= 7.2.3.1)
actionmailer (= 7.2.3.1)
actionpack (= 7.2.3.1)
actiontext (= 7.2.3.1)
actionview (= 7.2.3.1)
activejob (= 7.2.3.1)
activemodel (= 7.2.3.1)
activerecord (= 7.2.3.1)
activestorage (= 7.2.3.1)
activesupport (= 7.2.3.1)
bundler (>= 1.15.0)
railties (= 7.1.5.2)
rails-dom-testing (2.2.0)
railties (= 7.2.3.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
irb
railties (7.2.3.1)
actionpack (= 7.2.3.1)
activesupport (= 7.2.3.1)
cgi
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
rbs (4.0.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.26.4)
@@ -766,7 +765,7 @@ GEM
redis-namespace (1.10.0)
redis (>= 4)
regexp_parser (2.10.0)
reline (0.3.6)
reline (0.6.3)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
@@ -861,7 +860,8 @@ GEM
ruby_llm-schema (~> 0)
zeitwerk (~> 2)
ruby_llm-schema (0.3.0)
ruby_parser (3.20.0)
ruby_parser (3.22.0)
racc (~> 1.5)
sexp_processor (~> 4.16)
sass (3.7.4)
sass-listen (~> 4.0.0)
@@ -911,12 +911,12 @@ GEM
zeitwerk (~> 2.5)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.3.1)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
sidekiq (7.3.10)
base64
connection_pool (>= 2.3.0, < 3)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
rack (>= 2.2.4, < 3.3)
redis-client (>= 0.23.0, < 1)
sidekiq-cron (2.4.0)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
@@ -950,6 +950,8 @@ GEM
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
sorbet-runtime (0.5.11934)
speedshop-cloudwatch (0.2.1)
aws-sdk-cloudwatch (>= 1.81.0)
spring (4.1.1)
spring-watcher-listen (2.1.0)
listen (>= 2.7, < 4.0)
@@ -967,7 +969,8 @@ GEM
statsd-ruby (1.5.0)
stripe (18.0.1)
telephone_number (1.4.20)
test-prof (1.2.1)
test-prof (1.6.3)
logger
thor (1.4.0)
tidewave (0.2.0)
fast-mcp (~> 1.5.0)
@@ -979,6 +982,7 @@ GEM
i18n
timeout (0.6.1)
trailblazer-option (0.1.2)
tsort (0.2.0)
twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0)
@@ -987,7 +991,7 @@ GEM
oauth
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2023.3)
tzinfo-data (1.2026.4)
tzinfo (>= 1.0.0)
uber (0.1.0)
uglifier (4.2.0)
@@ -1003,6 +1007,7 @@ GEM
uri (1.1.1)
uri-idna (0.3.1)
uri_template (0.7.0)
useragent (0.16.11)
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
@@ -1010,11 +1015,11 @@ GEM
vite_rails (3.10.0)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.10.2)
vite_ruby (3.10.6)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1)
rack-proxy (>= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9)
rack (>= 2.0.9)
@@ -1030,7 +1035,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.7)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -1057,14 +1062,15 @@ DEPENDENCIES
acts-as-taggable-on
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.10.0)
administrate-field-belongs_to_search (>= 0.10.0)
ai-agents (>= 0.12.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
aws-sdk-s3
azure-storage-blob!
aws-sdk-sesv2
azure-blob
barnes
bootsnap
brakeman
@@ -1080,7 +1086,7 @@ DEPENDENCIES
datadog (~> 2.0)
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password!
devise-secure_password (= 2.2.1)
devise-two-factor (>= 5.0.0)
devise_token_auth (>= 1.2.3)
dotenv-rails (>= 3.0.0)
@@ -1149,7 +1155,7 @@ DEPENDENCIES
rack-cors (= 2.0.0)
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.1)
rails (= 7.2.3.1)
rails-i18n (~> 7.0)
redis
redis-namespace
@@ -1175,13 +1181,14 @@ DEPENDENCIES
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
sidekiq (~> 7.3, >= 7.3.1)
sidekiq (~> 7.3.10)
sidekiq-cron (>= 2.4.0)
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
skooma
slack-ruby-client (~> 2.7.0)
speedshop-cloudwatch (~> 0.2.1)
spring
spring-watcher-listen
squasher

View File

@@ -1,4 +1,4 @@
Copyright (c) 2017-2024 Chatwoot Inc.
Copyright (c) 2017-2026 Chatwoot Inc.
Portions of this software are licensed as follows:

View File

@@ -1,4 +1,4 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite build --watch
vite: bin/vite build --watch --force

View File

@@ -1 +1 @@
4.15.1
4.18.0

View File

@@ -13,6 +13,7 @@ class ContactMergeAction
merge_messages
merge_contact_inboxes
merge_contact_notes
merge_calls
merge_and_remove_mergee_contact
end
@base_contact
@@ -46,6 +47,10 @@ class ContactMergeAction
ContactInbox.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
end
def merge_calls
# overridden in enterprise/app/actions/enterprise/contact_merge_action.rb
end
def merge_and_remove_mergee_contact
mergable_attribute_keys = %w[identifier name email phone_number additional_attributes custom_attributes]
base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank
@@ -53,10 +58,17 @@ class ContactMergeAction
# attributes in base contact are given preference
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
merged_attributes['contact_type'] = higher_contact_type
@mergee_contact.reload.destroy!
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
@base_contact.update!(merged_attributes)
end
def higher_contact_type
[base_contact, mergee_contact].map(&:contact_type).max_by { |type| Contact.contact_types[type] }
end
end
ContactMergeAction.prepend_mod_with('ContactMergeAction')

View File

@@ -2,4 +2,3 @@
//= link administrate/application.css
//= link administrate/application.js
//= link administrate-field-active_storage/application.css
//= link secretField.js

View File

@@ -1,45 +0,0 @@
// eslint-disable-next-line
function toggleSecretField(e) {
e.preventDefault();
e.stopPropagation();
const toggler = e.currentTarget;
const secretField = toggler.parentElement;
const textElement = secretField.querySelector('[data-secret-masked]');
if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10;
textElement.textContent = '•'.repeat(maskedLength);
textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
return;
}
textElement.textContent = secretField.dataset.secretText;
textElement.dataset.secretMasked = 'false';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-hide');
}
// eslint-disable-next-line
function copySecretField(e) {
e.preventDefault();
e.stopPropagation();
const toggler = e.currentTarget;
const secretField = toggler.parentElement;
navigator.clipboard.writeText(secretField.dataset.secretText);
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});

View File

@@ -1,32 +1,5 @@
@charset 'utf-8';
@import 'reset/normalize';
@import 'utilities/variables';
@import 'utilities/text-color';
// The super admin is styled by app/javascript/entrypoints/superadmin.scss.
// Sprockets only serves the selectize base styles its pickers need.
@import 'selectize';
@import 'library/clearfix';
@import 'library/data-label';
@import 'library/variables';
@import 'base/forms';
@import 'base/layout';
@import 'base/lists';
@import 'base/tables';
@import 'base/typography';
@import 'components/app-container';
@import 'components/attributes';
@import 'components/buttons';
@import 'components/cells';
@import 'components/field-unit';
@import 'components/flashes';
@import 'components/form-actions';
@import 'components/main-content';
@import 'components/pagination';
@import 'components/search';
@import 'components/reports';
@import 'custom_styles';

View File

@@ -1,96 +0,0 @@
fieldset {
background-color: transparent;
border: 0;
margin: 0;
padding: 0;
}
legend {
font-weight: $font-weight-medium;
margin: 0;
padding: 0;
}
label {
display: block;
font-weight: $font-weight-medium;
margin: 0;
}
input,
select {
display: block;
font-family: $base-font-family;
font-size: $base-font-size;
}
[type="color"],
[type="date"],
[type="datetime-local"],
[type="email"],
[type="month"],
[type="number"],
[type="password"],
[type="search"],
[type="tel"],
[type="text"],
[type="time"],
[type="url"],
[type="week"],
input:not([type]),
textarea {
appearance: none;
background-color: $white;
border: $base-border;
border-radius: $base-border-radius;
font-family: $base-font-family;
padding: 0.5em;
transition: border-color $base-duration $base-timing;
width: 100%;
&:hover {
border-color: mix($black, $base-border-color, 20%);
}
&:focus {
border-color: $action-color;
outline: none;
}
&:disabled {
background-color: mix($black, $white, 5%);
cursor: not-allowed;
&:hover {
border: $base-border;
}
}
}
textarea {
resize: vertical;
}
[type="checkbox"],
[type="radio"] {
display: inline;
margin-right: $small-spacing / 2;
}
[type="file"] {
width: 100%;
}
select {
width: 100%;
}
[type="checkbox"],
[type="radio"],
[type="file"],
select {
&:focus {
outline: $focus-outline;
outline-offset: $focus-outline-offset;
}
}

View File

@@ -1,22 +0,0 @@
html {
background-color: $color-white;
box-sizing: border-box;
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
figure {
margin: 0;
}
img,
picture {
margin: 0;
max-width: 100%;
}

View File

@@ -1,19 +0,0 @@
ul,
ol {
list-style-type: none;
margin: 0;
padding: 0;
}
dl {
margin-bottom: $small-spacing;
dt {
font-weight: $font-weight-medium;
margin-top: $small-spacing;
}
dd {
margin: 0;
}
}

View File

@@ -1,71 +0,0 @@
table {
border-collapse: collapse;
font-size: $font-size-default;
text-align: left;
width: 100%;
a {
color: inherit;
text-decoration: none;
}
}
tr {
border-bottom: $base-border;
th {
font-weight: $font-weight-medium;
&.cell-label--avatar-field {
a {
display: none;
}
}
}
}
tbody tr {
&:hover {
background-color: $base-background-color;
cursor: pointer;
}
&:focus {
outline: $focus-outline;
outline-offset: -($focus-outline-width);
}
td {
&.cell-data--avatar-field {
line-height: 1;
text-align: center;
img {
border-radius: 50%;
height: $space-large;
max-height: $space-large;
width: $space-large;
}
}
}
}
td,
th {
padding: $space-slab;
vertical-align: middle;
}
td:first-child,
th:first-child {
padding-left: 0;
}
td:last-child,
th:last-child {
padding-right: 0;
}
td img {
max-height: 2rem;
}

View File

@@ -1,44 +0,0 @@
body {
color: $base-font-color;
font-family: $base-font-family;
font-size: $base-font-size;
line-height: $base-line-height;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: $heading-font-family;
font-size: $base-font-size;
line-height: $heading-line-height;
margin: 0;
}
p {
margin: 0 0 $small-spacing;
}
a {
color: $action-color;
transition: color $base-duration $base-timing;
&:hover {
color: mix($black, $action-color, 25%);
}
&:focus {
outline: $focus-outline;
outline-offset: $focus-outline-offset;
}
}
hr {
border-bottom: $base-border;
border-left: 0;
border-right: 0;
border-top: 0;
margin: $base-spacing 0;
}

View File

@@ -1,8 +0,0 @@
.app-container {
align-items: stretch;
display: flex;
margin-left: auto;
margin-right: auto;
max-width: 100rem;
min-height: 100vh;
}

View File

@@ -1,26 +0,0 @@
.attribute-label {
@include data-label;
clear: left;
float: left;
margin-bottom: $base-spacing;
margin-top: 0.25em;
text-align: left;
width: calc(16% - 1rem);
}
.preserve-whitespace {
white-space: pre-wrap;
word-wrap: break-word;
}
.attribute-data {
float: left;
margin-bottom: $base-spacing;
margin-left: 1.25rem;
width: calc(84% - 0.625rem);
}
.attribute--nested {
border: $base-border;
padding: $small-spacing;
}

View File

@@ -1,50 +0,0 @@
button:not(.reset-base),
input[type='button']:not(.reset-base),
input[type='reset']:not(.reset-base),
input[type='submit']:not(.reset-base),
.button:not(.reset-base) {
appearance: none;
background-color: $color-woot;
border: 0;
border-radius: $base-border-radius;
color: $white;
cursor: pointer;
display: inline-flex;
font-size: $font-size-small;
-webkit-font-smoothing: antialiased;
font-weight: $font-weight-medium;
line-height: 1;
padding: $space-one $space-two;
text-decoration: none;
transition: background-color $base-duration $base-timing;
user-select: none;
vertical-align: middle;
white-space: nowrap;
&:hover {
background-color: mix($black, $color-woot, 20%);
color: $white;
}
&:focus {
outline: $focus-outline;
outline-offset: $focus-outline-offset;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
background-color: $color-woot;
}
}
}
.button--alt {
background-color: transparent;
border: $base-border;
border-color: $blue;
color: $blue;
margin-bottom: $base-spacing;
}

View File

@@ -1,70 +0,0 @@
.cell-label {
&:hover {
a {
color: $action-color;
}
svg {
fill: $action-color;
transform: rotate(180deg);
}
}
a {
color: inherit;
display: inline-block;
transition: color $base-duration $base-timing;
width: 100%;
}
}
.cell-label--asc,
.cell-label--desc {
font-weight: $font-weight-medium;
}
.cell-label__sort-indicator {
float: right;
margin-left: 5px;
svg {
fill: $hint-grey;
height: 13px;
transition: transform $base-duration $base-timing;
width: 13px;
}
}
.cell-label__sort-indicator--desc {
transform: rotate(180deg);
}
.cell-data--number,
.cell-label--number {
text-align: right;
}
.cell-data__secret-field {
align-items: center;
color: $hint-grey;
display: flex;
span {
flex: 0 0 auto;
}
[data-secret-toggler],
[data-secret-copier] {
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
svg {
fill: currentColor;
height: 1.25rem;
width: 1.25rem;
}
}
}

View File

@@ -1,54 +0,0 @@
.field-unit {
@include administrate-clearfix;
align-items: center;
display: flex;
margin-bottom: $base-spacing;
position: relative;
width: 100%;
}
.field-unit__label {
float: left;
margin-left: 0.625rem;
text-align: right;
width: calc(15% - 0.625rem);
}
.field-unit__field {
float: left;
margin-left: 1.25rem;
max-width: 31.15rem;
width: 100%;
}
.field-unit--nested {
border: $base-border;
margin-left: 7.5%;
max-width: 37.5rem;
padding: $small-spacing;
width: 100%;
.field-unit__field {
width: 100%;
}
.field-unit__label {
width: 10rem;
}
}
.field-unit--required {
label::after {
color: $red;
content: ' *';
}
}
.attribute-data--avatar-field {
height: $space-larger;
width: $space-larger;
img {
border-radius: 50%;
}
}

View File

@@ -1,28 +0,0 @@
$base-spacing: 1.5em !default;
$flashes: (
"alert": #fff6bf,
"error": #fbe3e4,
"notice": #e5edf8,
"success": #e6efc2,
) !default;
@each $flash-type, $color in $flashes {
.flash-#{$flash-type} {
background-color: $color;
color: mix($black, $color, 60%);
display: block;
margin-bottom: $base-spacing / 2;
padding: $base-spacing / 2;
text-align: center;
a {
color: mix($black, $color, 70%);
text-decoration: underline;
&:focus,
&:hover {
color: mix($black, $color, 90%);
}
}
}
}

View File

@@ -1,3 +0,0 @@
.form-actions {
margin-left: calc(15% + 1.25rem);
}

View File

@@ -1,35 +0,0 @@
.main-content {
font-size: $font-size-default;
left: 21rem;
position: absolute;
right: 0;
top: 0;
}
.main-content__body {
font-size: $font-size-small;
padding: $space-two;
table {
font-size: $font-size-small;
}
form {
margin-top: $space-two;
}
}
.main-content__header {
align-items: center;
background-color: $color-white;
border-bottom: 1px solid $color-border;
display: flex;
min-height: 3.5rem;
padding: $space-small $space-normal;
}
.main-content__page-title {
font-size: $font-size-medium;
font-weight: $font-weight-medium;
margin-right: auto;
}

View File

@@ -1,19 +0,0 @@
.pagination {
font-size: $font-size-default;
margin-top: $base-spacing;
padding-left: $base-spacing;
padding-right: $base-spacing;
text-align: center;
.first,
.prev,
.page,
.next,
.last {
margin: $small-spacing;
}
.current {
font-weight: $font-weight-medium;
}
}

View File

@@ -1,15 +0,0 @@
.report--list {
display: flex;
padding: 0 $space-two $space-larger;
}
.report-card {
flex: 1;
font-size: $font-size-small;
text-align: center;
.metric {
font-size: $font-size-bigger;
font-weight: 200;
}
}

View File

@@ -1,44 +0,0 @@
.search {
margin-left: auto;
margin-right: 1.25rem;
max-width: 27.5rem;
position: relative;
width: 100%;
}
.search__input {
background: $grey-1;
padding-left: $space-normal * 2.5;
padding-right: $space-normal * 2.5;
}
.search__eyeglass-icon {
fill: $grey-7;
height: $space-normal;
left: $space-normal;
position: absolute;
top: 50%;
transform: translateY(-50%);
width: $space-normal;
}
.search__clear-link {
height: $space-normal;
position: absolute;
right: $space-normal * 0.75;
top: 50%;
transform: translateY(-50%);
width: $space-normal;
}
.search__clear-icon {
fill: $grey-5;
height: $space-normal;
position: absolute;
transition: fill $base-duration $base-timing;
width: $space-normal;
&:hover {
fill: $action-color;
}
}

View File

@@ -1,23 +0,0 @@
// custom styles for the dashboard
.feature-cell {
background: $color-extra-light-blue;
border-radius: 10px;
float: left;
margin-left: 8px;
margin-top: 6px;
padding: 4px 12px;
.icon-container {
margin-right: 2px;
}
.value-container {
margin-left: 6px;
}
}
.feature-container {
max-width: 100rem;
}

View File

@@ -1,7 +0,0 @@
@mixin administrate-clearfix {
&::after {
clear: both;
content: '';
display: block;
}
}

View File

@@ -1,8 +0,0 @@
@mixin data-label {
color: $hint-grey;
font-size: 0.8em;
font-weight: 400;
letter-spacing: 0.0357em;
position: relative;
text-transform: uppercase;
}

View File

@@ -1,61 +0,0 @@
// Typography
$base-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif !default;
$heading-font-family: $base-font-family !default;
$base-font-size: 16px !default;
$base-line-height: 1.5 !default;
$heading-line-height: 1.2 !default;
// Other Sizes
$base-border-radius: 4px !default;
$base-spacing: $base-line-height * 1em !default;
$small-spacing: $base-spacing / 2 !default;
// Colors
$white: #fff !default;
$black: #000 !default;
$blue: #1f93ff !default;
$red: #ff382d !default;
$light-yellow: #ffc532 !default;
$light-green: #44ce4b !default;
$grey-0: #f6f7f7 !default;
$grey-1: #f0f4f5 !default;
$grey-2: #cfd8dc !default;
$grey-5: #adb5bd !default;
$grey-7: #293f54 !default;
$hint-grey: #7b808c !default;
// Font Colors
$base-font-color: $grey-7 !default;
$action-color: $blue !default;
// Background Colors
$base-background-color: $grey-0 !default;
// Focus
$focus-outline-color: transparentize($action-color, 0.4);
$focus-outline-width: 3px;
$focus-outline: $focus-outline-width solid $focus-outline-color;
$focus-outline-offset: 1px;
// Flash Colors
$flash-colors: (
alert: $light-yellow,
error: $red,
notice: mix($white, $blue, 50%),
success: $light-green
);
// Border
$base-border-color: $grey-1 !default;
$base-border: 1px solid $base-border-color !default;
// Transitions
$base-duration: 250ms !default;
$base-timing: ease-in-out !default;

View File

@@ -1,448 +0,0 @@
/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in
* IE on Windows Phone and in iOS.
*/
html {
line-height: 1.15; /* 1 */
-ms-text-size-adjust: 100%; /* 2 */
-webkit-text-size-adjust: 100%; /* 2 */
-moz-osx-font-smoothing: grayscale;
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers (opinionated).
*/
body {
margin: 0;
}
/**
* Add the correct display in IE 9-.
*/
article,
aside,
footer,
header,
nav,
section {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* Add the correct display in IE 9-.
* 1. Add the correct display in IE.
*/
figcaption,
figure,
main { /* 1 */
display: block;
}
/**
* Add the correct margin in IE 8.
*/
figure {
margin: 1em 40px;
}
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* 1. Remove the gray background on active links in IE 10.
* 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
*/
a {
background-color: transparent; /* 1 */
-webkit-text-decoration-skip: objects; /* 2 */
}
/**
* 1. Remove the bottom border in Chrome 57- and Firefox 39-.
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
*/
b,
strong {
font-weight: inherit;
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font style in Android 4.3-.
*/
dfn {
font-style: italic;
}
/**
* Add the correct background and color in IE 9-.
*/
mark {
background-color: #ff0;
color: #000;
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
audio,
video {
display: inline-block;
}
/**
* Add the correct display in iOS 4-7.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Remove the border on images inside links in IE 10-.
*/
img {
border-style: none;
}
/**
* Hide the overflow in IE.
*/
svg:not(:root) {
overflow: hidden;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers (opinionated).
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: sans-serif; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
* controls in Android 4.
* 2. Correct the inability to style clickable types in iOS and Safari.
*/
button,
html [type="button"], /* 1 */
[type="reset"],
[type="submit"] {
-webkit-appearance: button; /* 2 */
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* 1. Add the correct display in IE 9-.
* 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/**
* Remove the default vertical scrollbar in IE.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10-.
* 2. Remove the padding in IE 10-.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in IE 9-.
* 1. Add the correct display in Edge, IE, and Firefox.
*/
details, /* 1 */
menu {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Scripting
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
canvas {
display: inline-block;
}
/**
* Add the correct display in IE.
*/
template {
display: none;
}
/* Hidden
========================================================================== */
/**
* Add the correct display in IE 10-.
*/
[hidden] {
display: none;
}

View File

@@ -1,3 +0,0 @@
.text-color-red {
color: $alert-color;
}

View File

@@ -1,90 +0,0 @@
// Font sizes
$font-size-nano: 0.5rem;
$font-size-micro: 0.675rem;
$font-size-mini: 0.75rem;
$font-size-small: 0.875rem;
$font-size-default: 1rem;
$font-size-medium: 1.125rem;
$font-size-large: 1.375rem;
$font-size-big: 1.5rem;
$font-size-bigger: 1.75rem;
$font-size-mega: 2.125rem;
$font-size-giga: 2.5rem;
// spaces
$zero: 0;
$space-micro: 0.125rem;
$space-smaller: 0.25rem;
$space-small: 0.5rem;
$space-one: 0.675rem;
$space-slab: 0.75rem;
$space-normal: 1rem;
$space-two: 1.25rem;
$space-medium: 1.5rem;
$space-large: 2rem;
$space-larger: 3rem;
$space-jumbo: 4rem;
$space-mega: 6.25rem;
// font-weight
$font-weight-feather: 100;
$font-weight-light: 300;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-bold: 600;
$font-weight-black: 700;
//Navbar
$nav-bar-width: 23rem;
$header-height: 5.6rem;
$woot-logo-padding: $space-large $space-two;
// Colors
$color-woot: #1f93ff;
$color-gray: #6e6f73;
$color-light-gray: #747677;
$color-border: #e0e6ed;
$color-border-light: #f0f4f5;
$color-background: #f4f6fb;
$color-border-dark: #cad0d4;
$color-background-light: #f9fafc;
$color-white: #fff;
$color-body: #3c4858;
$color-heading: #1f2d3d;
$color-extra-light-blue: #f5f7f9;
$primary-color: $color-woot;
$secondary-color: #5d7592;
$success-color: #44ce4b;
$warning-color: #ffc532;
$alert-color: #ff382d;
$masked-bg: rgba(0, 0, 0, .4);
// Color-palettes
$color-primary-light: #c7e3ff;
$color-primary-dark: darken($color-woot, 20%);
// Thumbnail
$thumbnail-radius: 4rem;
// chat-header
$conv-header-height: 4rem;
// Inbox List
$inbox-thumb-size: 4.8rem;
// Snackbar default
$woot-snackbar-bg: #323232;
$woot-snackbar-button: #ffeb3b;
$swift-ease-out-duration: .4s !default;
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
// Transitions
$transition-ease-in: all 0.250s ease-in;

View File

@@ -70,12 +70,17 @@ class AccountBuilder
end
def create_user
@user = build_user
@user.save!
end
def build_user
@user = User.new(email: @email,
password: user_password,
password_confirmation: user_password,
name: user_full_name)
@user.type = 'SuperAdmin' if @super_admin
@user.confirm if @confirmed
@user.save!
@user
end
end

View File

@@ -2,6 +2,14 @@
# It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction.
class AgentBuilder
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
class LimitExceededError < StandardError
def initialize
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
end
end
# Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
@@ -14,30 +22,51 @@ class AgentBuilder
# Creates a user and account user in a transaction.
# @return [User] the created user.
def perform
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
account.with_lock do
raise LimitExceededError unless can_add_agent?
ActiveRecord::Base.transaction(requires_new: true) do
@user = find_or_create_user
create_account_user
reserve_invitation_email_capacity if user_needs_confirmation?
end
end
@user.send_confirmation_instructions if user_needs_confirmation?
@user
end
private
def can_add_agent?
account.usage_limits[:agents] > account.account_users.count
end
# Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user.
def find_or_create_user
@new_user = false
user = User.from_email(email)
return user if user
@name = email.split('@').first if @name.blank?
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
User.new(email: email, name: @name, password: temp_password, password_confirmation: temp_password).tap do |new_user|
new_user.skip_confirmation_notification!
new_user.save!
@new_user = true
end
end
# Checks if the user needs confirmation.
# @return [Boolean] true if the user is persisted and not confirmed, false otherwise.
def user_needs_confirmation?
@user.persisted? && !@user.confirmed?
@new_user && @user.persisted? && !@user.confirmed?
end
def reserve_invitation_email_capacity
return if account.reserve_email_send_capacity
raise CustomExceptions::Account::EmailLimitExceeded.new({})
end
# Creates an account user linking the user to the current account.

View File

@@ -67,7 +67,7 @@ class ContactInboxWithContactBuilder
def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_phone_numbers
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact
@@ -107,9 +107,14 @@ class ContactInboxWithContactBuilder
account.contacts.from_email(email)
end
def find_contact_by_phone_number(phone_number)
return if phone_number.blank?
def find_contact_by_phone_numbers
phone_numbers = [contact_attributes[:phone_number], *Array(contact_attributes[:phone_number_candidates])].compact_blank.uniq
account.contacts.find_by(phone_number: phone_number)
phone_numbers.each do |phone_number|
contact = account.contacts.find_by(phone_number: phone_number)
return contact if contact
end
nil
end
end

View File

@@ -25,12 +25,13 @@ class Email::BaseBuilder
# Friendly: <agent_name> from <business_name>
# Professional: <business_name>
if inbox.friendly?
I18n.t(
'conversations.reply.email.header.friendly_name',
Email::SenderNameBuilder.new(
account: account,
sender: message&.sender,
sender_email: sender_email,
sender_name: custom_sender_name,
business_name: business_name,
from_email: sender_email
)
business_name: business_name
).build
else
I18n.t(
'conversations.reply.email.header.professional_name',

View File

@@ -0,0 +1,46 @@
class Email::SenderNameBuilder
TRANSLATION_KEY = 'conversations.reply.email.header.friendly_name'.freeze
pattr_initialize [:account!, :sender!, :sender_email!, :sender_name!, :business_name!]
def build
address = Mail::Address.new(sender_email)
address.display_name = localized_display_name(address.address)
address.format
rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError
localized_header(sender_email)
end
private
def localized_display_name(email_address)
localized_header(email_address).sub(mailbox_pattern(email_address), '').strip
end
def localized_header(email_address)
I18n.t(
TRANSLATION_KEY,
sender_name: sender_name,
business_name: business_name,
from_email: email_address,
locale: sender_locale
)
end
def mailbox_pattern(email_address)
/\s*[<«]\s*(?:reply\+)?#{Regexp.escape(email_address)}\s*[>»]\s*/
end
def sender_locale
locale_candidates.find { |locale| valid_locale?(locale) && I18n.exists?(TRANSLATION_KEY, locale, fallback: false) } || I18n.default_locale
end
def locale_candidates
sender_locale = sender.ui_settings&.dig('locale') if sender.is_a?(User)
[sender_locale, account.locale, I18n.default_locale].compact.map(&:to_s).uniq
end
def valid_locale?(locale)
I18n.available_locales.map(&:to_s).include?(locale)
end
end

View File

@@ -91,15 +91,17 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'],
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
external_url: attachment['url'] || attachment.dig('payload', 'url')
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Both `share` and `post` attachment types carry a page URL rather than a media file,
# so map them to `fallback` (which keeps the title/link without attempting a download).
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if type.to_sym == :share
return :fallback if [:share, :post].include?(type.to_sym)
super
end
@@ -131,8 +133,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def process_contact_params_result(result)
name = result['name'].presence || [result['first_name'], result['last_name']].compact_blank.join(' ').presence || 'John Doe'
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
name: name,
account_id: @inbox.account_id,
avatar_url: result['profile_pic']
}

View File

@@ -7,7 +7,7 @@ class Messages::MessageBuilder
def initialize(user, conversation, params)
@params = params
@private = params[:private] || false
@private = ActiveModel::Type::Boolean.new.cast(params[:private]) || false
@conversation = conversation
@user = user
@account = conversation.account
@@ -49,9 +49,7 @@ class Messages::MessageBuilder
end
def process_attachments
return if @attachments.blank?
@attachments.each do |uploaded_attachment|
(Array(@attachments) + forwarded_attachments).each do |uploaded_attachment|
attachment = @message.attachments.build(
account_id: @message.account_id,
file: uploaded_attachment
@@ -76,7 +74,27 @@ class Messages::MessageBuilder
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
end
def forwarded_message_id
content_attributes[:forwarded_message_id]
end
def forwarded_attachments
return [] if forwarded_message_id.blank?
forwarded_message = @conversation.messages.find(forwarded_message_id)
forwarded_message.attachments.where(id: @params[:forwarded_attachment_ids]).map { |attachment| attachment.file.blob }
end
def validate_forward
return if forwarded_message_id.blank?
raise StandardError, 'Forwarded emails need an email inbox' unless @conversation.inbox.email?
raise StandardError, 'Forwarded emails cannot be private' if @private
raise StandardError, 'Forwarded emails need a recipient' if process_email_string(@params[:to_emails]).empty?
end
def process_emails
validate_forward
return unless @conversation.inbox&.inbox_type == 'Email'
cc_emails = process_email_string(@params[:cc_emails])

View File

@@ -0,0 +1,213 @@
class V2::Reports::DrilldownBuilder
include DateRangeHelper
include TimezoneHelper
DEFAULT_GROUP_BY = 'day'.freeze
DEFAULT_PAGE = 1
DEFAULT_PER_PAGE = 25
MAX_PER_PAGE = 100
SUPPORTED_GROUP_BY = %w[hour day week month year].freeze
SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze
MESSAGE_METRICS = {
'incoming_messages_count' => :incoming,
'outgoing_messages_count' => :outgoing
}.freeze
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
pattr_initialize :account, :params
def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s)
def build
records = paginated_records.to_a
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
end
private
def meta
{
metric: metric,
record_type: record_type,
bucket: {
since: bucket_range.begin.to_i,
until: bucket_range.end.to_i
},
current_page: current_page,
per_page: per_page,
total_count: paginated_records.total_count,
conversation_count: conversation_count
}
end
def conversation_count
return paginated_records.total_count if conversation_metric?
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
end
def paginated_records
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
end
def drilldown_scope
if message_metric?
message_scope
elsif conversation_metric?
conversation_scope
else
reporting_event_scope
end
end
def message_scope
scope.messages
.where(account_id: account.id, created_at: bucket_range)
.public_send(MESSAGE_METRICS.fetch(metric))
.includes(:sender, conversation: [:assignee, :contact, :inbox])
.reorder(created_at: :desc)
end
def conversation_scope
scope.conversations
.where(account_id: account.id, created_at: bucket_range)
.includes(:assignee, :contact, :inbox)
.order(created_at: :desc)
end
def reporting_event_scope
events = scope.reporting_events
.where(account_id: account.id, name: raw_event_name, created_at: bucket_range)
.includes(:user, :inbox, conversation: [:assignee, :contact, :inbox])
.order(created_at: :desc)
if raw_count_strategy == :exclude_bot_handoffs
events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
elsif raw_count_strategy == :distinct_conversation
events = events.where(id: distinct_conversation_event_ids(events))
end
events
end
def bot_handoff_conversation_ids_subquery
scope.reporting_events
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
end
def distinct_conversation_event_ids(events)
events.reorder(nil)
.where.not(conversation_id: nil)
.select('MAX(reporting_events.id)')
.group(:conversation_id)
end
def record_serializer(records)
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(
account,
metric,
use_business_hours?,
records
)
end
def bucket_range
@bucket_range ||= begin
bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone)
bucket_end = bucket_end_for(bucket_start)
requested_start = Time.zone.at(params[:since].to_i)
requested_end = Time.zone.at(params[:until].to_i)
[bucket_start, requested_start].max...[bucket_end, requested_end].min
end
end
def bucket_end_for(bucket_start)
{
'hour' => bucket_start + 1.hour,
'day' => bucket_start + 1.day,
'week' => bucket_start + 1.week,
'month' => bucket_start + 1.month,
'year' => bucket_start + 1.year
}.fetch(group_by)
end
def scope
case dimension_type
when 'account' then account
when 'inbox' then inbox
when 'agent' then user
when 'label' then label
when 'team' then team
else
raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}"
end
end
def inbox = @inbox ||= account.inboxes.find(params[:id])
def user = @user ||= account.users.find(params[:id])
def label = @label ||= account.labels.find(params[:id])
def team = @team ||= account.teams.find(params[:id])
def metric
params[:metric].to_s
end
def report_metric
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
end
def raw_event_name
report_metric&.raw_event_name
end
def raw_count_strategy
report_metric&.raw_count_strategy
end
def record_type
return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric)
'conversation'
end
def message_metric?
MESSAGE_METRICS.key?(metric)
end
def conversation_metric?
metric == 'conversations_count'
end
def dimension_type
(params[:type].presence || 'account').to_s
end
def group_by
@group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
def current_page
[params[:page].to_i, DEFAULT_PAGE].max
end
def per_page
requested_per_page = params[:per_page].to_i
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
[requested_per_page, MAX_PER_PAGE].min
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
end

View File

@@ -0,0 +1,199 @@
class V2::Reports::DrilldownRecordSerializer
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
attr_reader :account, :metric, :use_business_hours, :records
def initialize(account, metric, use_business_hours, records = [])
@account = account
@metric = metric
@use_business_hours = use_business_hours
@records = records
end
def serialize(record)
return serialize_message(record) if record.is_a?(Message)
return serialize_conversation_event(record) if record.is_a?(ReportingEvent)
serialize_conversation(record)
end
private
def serialize_message(message, metric_value: nil, occurred_at: nil)
{
record_type: 'message',
conversation: conversation_attributes(message.conversation),
message: message_attributes(message),
metric_value: metric_value,
occurred_at: (occurred_at || message.created_at).to_i
}
end
def serialize_conversation_event(event)
inferred_message = inferred_message_for(event)
if inferred_message.present?
return serialize_message(
inferred_message,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event)
)
end
serialize_conversation(
event.conversation,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event),
event_name: event.name
)
end
def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil)
serialized_record = {
record_type: 'conversation',
conversation: conversation_attributes(conversation),
message: nil,
metric_value: metric_value,
occurred_at: (occurred_at || conversation&.created_at)&.to_i
}
serialized_record[:event_name] = event_name if event_name.present?
serialized_record
end
def conversation_attributes(conversation)
return {} if conversation.blank?
{
id: conversation.id,
display_id: conversation.display_id,
contact_id: conversation.contact_id,
contact_name: conversation.contact&.name,
inbox_id: conversation.inbox_id,
inbox_name: conversation.inbox&.name,
assignee_id: conversation.assignee_id,
assignee_name: conversation.assignee&.name,
status: conversation.status,
created_at: conversation.created_at.to_i,
last_activity_at: conversation.last_activity_at.to_i,
last_message: last_message_attributes(conversation)
}
end
def message_attributes(message)
{
id: message.id,
content: message.content,
message_type: message.message_type,
sender_name: message.sender&.try(:name),
created_at: message.created_at.to_i
}
end
def last_message_attributes(conversation)
message = latest_messages_by_conversation_id[conversation.id]
return if message.blank?
message_attributes(message)
end
def inferred_message_for(event)
return unless MESSAGE_EVENT_METRICS.include?(metric)
return if event.conversation.blank? || event.event_end_time.blank?
inferred_messages_by_event_id[event.id]
end
def first_response_event_with_user?(event)
metric == 'avg_first_response_time' && event.user_id.present?
end
def message_inference_range(event)
(event.event_end_time - 1.second)..(event.event_end_time + 1.second)
end
def event_metric_value(event)
use_business_hours ? event.value_in_business_hours : event.value
end
def event_timestamp(event)
event.event_end_time || event.created_at
end
def latest_messages_by_conversation_id
@latest_messages_by_conversation_id ||= if conversation_ids.blank?
{}
else
latest_messages.index_by(&:conversation_id)
end
end
def latest_messages
Message
.where(account_id: account.id, conversation_id: conversation_ids)
.where.not(message_type: :activity)
.select('DISTINCT ON (messages.conversation_id) messages.*')
.reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC'))
.includes(:sender)
end
def inferred_messages_by_event_id
@inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id|
messages_by_event_id[event.id] = inferred_message_candidates.find do |message|
message_matches_event?(message, event)
end
end
end
def inferred_message_candidates
@inferred_message_candidates ||= if inference_events.blank?
[]
else
inferred_messages.to_a
end
end
def inferred_messages
Message
.where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq)
.where(created_at: inference_time_range)
.where(message_type: %i[outgoing template])
.includes(:sender)
.reorder(created_at: :desc, id: :desc)
end
def message_matches_event?(message, event)
message.conversation_id == event.conversation_id &&
message.created_at.between?(
message_inference_range(event).begin,
message_inference_range(event).end
) &&
message_sender_matches_event?(message, event)
end
def message_sender_matches_event?(message, event)
return true unless first_response_event_with_user?(event)
message.sender_id == event.user_id && message.sender_type == 'User'
end
def inference_time_range
event_end_times = inference_events.map(&:event_end_time)
(event_end_times.min - 1.second)..(event_end_times.max + 1.second)
end
def inference_events
@inference_events ||= records.select do |record|
record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present?
end
end
def conversation_ids
@conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq
end
def conversation_id_for(record)
return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent)
record.id
end
end

View File

@@ -1,5 +1,7 @@
class Api::BaseController < ApplicationController
include AccessTokenAuthHelper
# Raw API tokens must bypass DeviseTokenAuth's encoded dashboard bearer parsing.
skip_before_action :set_current_user, if: :authenticate_by_access_token?
respond_to :json
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
@@ -7,10 +9,6 @@ class Api::BaseController < ApplicationController
private
def authenticate_by_access_token?
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
end
def check_authorization(model = nil)
model ||= controller_name.classify.constantize

View File

@@ -1,5 +1,4 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]

View File

@@ -1,8 +1,6 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index, :bulk_create]
before_action :check_authorization
before_action :validate_limit, only: [:create]
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
def index
@agents = agents
@@ -20,6 +18,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
)
@agent = builder.perform
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
def update
@@ -36,25 +36,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def bulk_create
emails = params[:emails]
emails.each do |email|
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
begin
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
bulk_create_agents(emails)
# This endpoint is used to bulk create agents during onboarding
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
clear_onboarding_step
head :ok
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
private
@@ -87,22 +75,41 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end
def validate_limit_for_bulk_create
limit_available = params[:emails].count <= available_agent_count
def bulk_create_agents(emails)
email_limit_error = nil
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
Current.account.with_lock do
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
emails.each do |email|
create_agent_from_email(email)
rescue CustomExceptions::Account::EmailLimitExceeded => e
email_limit_error = e
end
end
raise email_limit_error if email_limit_error
end
def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
def create_agent_from_email(email)
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
def clear_onboarding_step
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
end
def available_agent_count
Current.account.usage_limits[:agents] - agents.count
end
def can_add_agent?
available_agent_count.positive?
Current.account.usage_limits[:agents] - Current.account.account_users.count
end
def delete_user_record(agent)

View File

@@ -3,6 +3,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_article, except: [:index, :create, :reorder]
before_action :set_current_page, only: [:index]
before_action :validate_author, only: [:create]
before_action :discard_invalid_author, only: [:update]
def index
@portal_articles = @portal.articles
@@ -30,8 +32,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def update
@article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
persist_article_changes if params[:article].present?
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
end
def destroy
@@ -40,8 +42,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def reorder
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
render json: { positions: positions }
end
private
@@ -63,16 +65,52 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
@article = @portal.articles.find(params[:id])
end
def validate_author
author_id = params.dig(:article, :author_id)
return if author_id.blank?
return if author_belongs_to_account?(author_id)
render json: { error: 'Invalid author ID' }, status: :unprocessable_entity
end
def discard_invalid_author
submitted_article_params = params[:article]
return unless submitted_article_params&.key?(:author_id)
author_id = submitted_article_params[:author_id]
return if author_belongs_to_account?(author_id)
submitted_article_params.delete(:author_id)
end
def author_belongs_to_account?(author_id)
author_id.to_s.match?(/\A\d+\z/) && Current.account.users.exists?(id: author_id.to_i)
end
def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
# Draft-only autosaves must not bump the public-facing updated_at, so write
# them with update_columns (which skips the timestamp). update_columns also
# skips validations, so assign and validate first to avoid persisting content
# that exceeds the column length limit.
def persist_article_changes
keys = article_params.to_h.keys
if keys.any? && (keys - %w[draft_title draft_content]).empty?
@article.assign_attributes(article_params)
@article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
else
@article.update!(article_params)
end
end
def article_params
params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
:locale, meta: [:title,
:description,
{ tags: [] }]
:locale, :draft_title, :draft_content, meta: [:title,
:description,
{ tags: [] }]
)
end

View File

@@ -2,6 +2,8 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes
def index
# TODO: Remove this opt-in once mobile clients support AI assignees in this payload.
@include_ai_assignees = params[:include_ai_assignees].present?
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
@@ -10,6 +12,7 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_ai_assignees ? AgentBot.accessible_to(Current.account) : []
end
private
@@ -22,3 +25,5 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
params.permit(inbox_ids: [])
end
end
Api::V1::Accounts::AssignableAgentsController.prepend_mod_with('Api::V1::Accounts::AssignableAgentsController')

View File

@@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled
:fair_distribution_limit, :fair_distribution_window, :enabled,
:exclude_older_than_hours
)
end
end

View File

@@ -3,6 +3,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
before_action :ensure_execution_delay_allowed, only: [:create, :update]
def index
@automation_rules = Current.account.automation_rules
@@ -47,22 +48,45 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def clone
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
new_rule = automation_rule.dup
new_rule.save!
@automation_rule = new_rule
# Dropping the delay here would clone a paused wait into a rule that fires instantly on the
# next matching event, so refuse rather than rewrite what the rule means.
return render_delayed_automations_error if automation_rule.execution_delay.present? && !delayed_automations_enabled?
@automation_rule = automation_rule.dup
@automation_rule.save!
end
private
def automation_rules_permit
permitted_attributes = [:name, :description, :event_name, :active]
permitted_attributes << :execution_delay if delayed_automations_enabled?
params.permit(
:name, :description, :event_name, :active,
*permitted_attributes,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
end
def ensure_execution_delay_allowed
return if delayed_automations_enabled?
return if params[:execution_delay].blank?
render_delayed_automations_error
end
def render_delayed_automations_error
render json: { error: 'Delayed automations are not enabled for this account.' }, status: :unprocessable_entity
end
def delayed_automations_enabled?
Current.account.feature_enabled?('delayed_automations')
end
def fetch_automation_rule
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
end
end
Api::V1::Accounts::AutomationRulesController.prepend_mod_with('Api::V1::Accounts::AutomationRulesController')

View File

@@ -1,6 +1,17 @@
class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
include MfaEnforcementGuard
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :check_account_mfa_enforcement, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale
private
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
end

View File

@@ -0,0 +1,28 @@
class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
def show
set_branded_email_layout
end
def update
unless Current.account.feature_enabled?(:branded_email_templates)
render_could_not_create_error('Branded email templates feature is not enabled')
return
end
branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
set_branded_email_layout
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
private
def set_branded_email_layout
@branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
end
end
Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')

View File

@@ -1,4 +1,6 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :validate_assignment_targets, only: :create, if: -> { normalized_type == 'Conversation' }
def create
case normalized_type
when 'Conversation'
@@ -19,6 +21,19 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
params[:type].to_s.camelize
end
def validate_assignment_targets
fields = conversation_params[:fields] || {}
assignee_id = fields[:assignee_id]
return render_could_not_create_error('Invalid assignee_id') if !assignee_id.nil? && !@current_account.users.exists?(id: assignee_id)
team_id = fields[:team_id]
# The dashboard sends 0 to remove the team assignment; accept '0' from form-encoded requests too.
return if [nil, 0, '0'].include?(team_id) || @current_account.teams.exists?(id: team_id)
render_could_not_create_error('Invalid team_id')
end
def enqueue_conversation_job
::BulkActionsJob.perform_later(
account: @current_account,

View File

@@ -6,27 +6,21 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token]
page_id = params[:page_id]
inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token,
page_access_token: page_access_token
)
@facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel)
set_instagram_id(page_access_token, facebook_channel)
sync_page_details(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
end
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}"
# Additional log statements
log_additional_info
end
def log_additional_info
Rails.logger.debug do
"user_access_token: #{params[:user_access_token]} , page_access_token: #{params[:page_access_token]} ,
page_id: #{params[:page_id]}, inbox_name: #{params[:inbox_name]}"
end
end
def facebook_pages
@@ -40,15 +34,11 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
@page_details = mark_already_existing_facebook_pages(pages)
end
def set_instagram_id(page_access_token, facebook_channel)
fb_object = Koala::Facebook::API.new(page_access_token)
response = fb_object.get_connections('me', '', { fields: 'instagram_business_account' })
return if response['instagram_business_account'].blank?
instagram_id = response['instagram_business_account']['id']
facebook_channel.update(instagram_id: instagram_id)
def sync_page_details(page_access_token, facebook_channel)
page_details = Facebook::PageDetailsService.new(access_token: page_access_token).perform
facebook_channel.update(page_details.compact)
rescue StandardError => e
Rails.logger.error "Error in set_instagram_id: #{e.message}"
Rails.logger.error "Error in sync_page_details: #{e.message}"
end
# get params[:inbox_id], current_account. params[:omniauth_token]
@@ -76,7 +66,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
fb_page = get_fb_page(fb_page_id)
ActiveRecord::Base.transaction do
fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token)
set_instagram_id(access_token, fb_page)
sync_page_details(access_token, fb_page)
fb_page&.reauthorized!
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception

View File

@@ -13,7 +13,13 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
end
def update
@campaign.update!(campaign_params)
@campaign.with_lock do
if @campaign.one_off? && !@campaign.active?
render json: { error: 'The campaign has already started' }, status: :unprocessable_entity
else
@campaign.update!(campaign_params)
end
end
end
def destroy
@@ -24,7 +30,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
private
def campaign
@campaign ||= Current.account.campaigns.find_by(display_id: params[:id])
@campaign ||= Current.account.campaigns.find_by!(display_id: params[:id])
end
def campaign_params

View File

@@ -33,9 +33,10 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
def canned_responses
if params[:search]
search = params[:search].delete("\0")
Current.account.canned_responses
.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%")
.order_by_search(params[:search])
.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{search}%")
.order_by_search(search)
else
Current.account.canned_responses

View File

@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
@@ -8,8 +7,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
@current_account.save!
render json: preferences_payload
@@ -38,7 +37,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models)
existing_models.merge(permitted_captain_models).compact_blank.presence
end
def merged_captain_features
@@ -47,30 +46,38 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
end
def permitted_captain_models
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
end
def captain_feature_keys
Llm::Models.feature_keys.map(&:to_sym)
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
selected: account_models[feature_key] || config[:default]
model: route[:model],
selected: route[:model],
provider: route[:provider],
source: route[:source]
)
end
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration')
Llm::Models.default_model_for(feature_key)
end
end

View File

@@ -31,15 +31,6 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
render_result(result)
end
def label_suggestion
result = Captain::LabelSuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def follow_up
result = Captain::FollowUpService.new(
account: Current.account,

View File

@@ -6,6 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create
process_create
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -20,7 +22,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
ActiveRecord::Base.transaction do
authenticate_twilio
build_inbox
setup_webhooks if @twilio_channel.sms?
setup_webhooks
end
end
@@ -34,7 +36,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
end
def setup_webhooks
::Twilio::WebhookSetupService.new(inbox: @inbox).perform
::Twilio::WebhookSetupService.new(channel: @twilio_channel).perform
end
def phone_number

View File

@@ -1,4 +1,4 @@
class Api::V1::Accounts::Companies::BaseController < Api::V1::Accounts::EnterpriseAccountsController
class Api::V1::Accounts::Companies::BaseController < Api::V1::Accounts::BaseController
before_action :ensure_companies_enabled!
before_action :fetch_company

View File

@@ -1,5 +1,5 @@
class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Companies::BaseController
RESULTS_PER_PAGE = 15
RESULTS_PER_PAGE = 25
CONTACT_SEARCH_QUERY = [
'contacts.name ILIKE :search',
'contacts.email ILIKE :search',
@@ -13,7 +13,9 @@ class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Comp
before_action :fetch_contact, only: [:destroy]
def index
@contacts = fetch_contacts(@company.contacts.order(:name, :id))
contacts = @company.contacts.order(:name, :id)
contacts = contacts.where(CONTACT_SEARCH_QUERY, search: "%#{params[:q].strip}%") if params[:q].present?
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end

View File

@@ -0,0 +1,38 @@
class Api::V1::Accounts::Companies::ConversationsController < Api::V1::Accounts::Companies::BaseController
before_action :authorize_company_read!
before_action :set_counts
def index
@conversations = Conversations::SortService.apply(company_conversations, params[:sort_by])
.includes(:assignee, :contact, :inbox, :taggings)
.preload(ai_assignee: { avatar_attachment: [:blob] })
.page(params[:page] || 1)
end
def filter
@conversations = Companies::ConversationFilterService.new(
params.permit!, Current.user, Current.account, company: @company
).perform[:conversations]
render :index
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
private
def company_conversations
@company_conversations ||= Conversations::PermissionFilterService.new(
Current.account.conversations.where(contact_id: @company.contacts.select(:id)),
Current.user,
Current.account
).perform
end
def set_counts
@all_count = company_conversations.count
@open_count = company_conversations.open.count
end
end

View File

@@ -0,0 +1,14 @@
class Api::V1::Accounts::Companies::NotesController < Api::V1::Accounts::Companies::BaseController
RESULTS_PER_PAGE = 20
before_action :authorize_company_read!
def index
notes = Current.account.notes.where(contact_id: @company.contacts.select(:id))
notes = notes.where('notes.content ILIKE ?', "%#{Note.sanitize_sql_like(params[:q].strip)}%") if params[:q].present?
@notes = notes.latest
.includes(:contact, :user)
.page(params[:page] || 1)
.per(RESULTS_PER_PAGE)
end
end

View File

@@ -1,4 +1,4 @@
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::BaseController
include Sift
sort_on :name, type: :string
sort_on :domain, type: :string
@@ -6,12 +6,13 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :contacts_count, internal_name: :order_on_contacts_count, type: :scope, scope_params: [:direction]
RESULTS_PER_PAGE = 25
RESULTS_PER_PAGE = 50
before_action :ensure_companies_enabled!
before_action :check_authorization
before_action :set_current_page, only: [:index, :search]
before_action :fetch_company, only: [:show, :update, :destroy, :avatar, :destroy_custom_attributes]
before_action :validate_social_profiles, only: [:create, :update]
def index
@companies = fetch_companies(resolved_companies)
@@ -83,6 +84,14 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
@company = Current.account.companies.find(params[:id])
end
def validate_social_profiles
social_profiles = params.dig(:company, :additional_attributes, :social_profiles)
return unless social_profiles.is_a?(ActionController::Parameters)
return if social_profiles.values.all? { |url| url.to_s.match?(%r{\Ahttps?://}i) }
render json: { error: I18n.t('errors.companies.social_profiles.invalid') }, status: :unprocessable_entity
end
def company_params
params.require(:company).permit(
:name,

View File

@@ -0,0 +1,122 @@
module Api::V1::Accounts::Concerns::InboxHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_health_supported_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def message_templates
unless whatsapp_channel?
return render status: :unprocessable_entity, json: { error: 'Message templates are only available for WhatsApp channels' }
end
templates, last_sync_attempt_at, name_key = message_template_data
templates = templates.select { |template| template[name_key] == params[:name] } if params[:name].present?
render json: {
payload: templates,
meta: { last_sync_attempt_at: last_sync_attempt_at }
}
end
def health
render json: fetch_health_data
rescue Whatsapp::HealthService::ApiError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: {
error: {
type: e.authorization_error? ? 'authorization' : 'api',
message: e.message,
http_status: e.http_status,
code: e.code,
subcode: e.subcode
}.compact
}, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
register_channel_webhook
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def whatsapp_business_management_token
Whatsapp::BusinessManagementTokenService.new(whatsapp_channel).update!(params.require(:business_management_token))
head :no_content
rescue ArgumentError, ActiveRecord::RecordInvalid => e
render json: { error: e.message, message: e.message }, status: :unprocessable_entity
end
private
def fetch_health_data
return Whatsapp::HealthService.new(@inbox.channel).sync_health_status!(include_business_profile: true) if whatsapp_cloud_channel?
Twilio::HealthService.new(channel: @inbox.channel).perform
end
def register_channel_webhook
return Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback if whatsapp_cloud_channel?
Twilio::WebhookSetupService.new(channel: @inbox.channel).perform
# No-op unless voice is enabled; keeps the number's voice webhooks in sync alongside messaging.
@inbox.channel.try(:reprovision_voice_webhooks!)
end
def validate_health_supported_channel
return if whatsapp_cloud_channel? || twilio_sms_channel?
render json: { error: 'Health data only available for WhatsApp Cloud API and Twilio SMS channels' }, status: :bad_request
end
def whatsapp_channel
channel = @inbox.channel
raise ActiveRecord::RecordNotFound unless channel.is_a?(Channel::Whatsapp)
channel
end
def whatsapp_cloud_channel?
@inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
end
def twilio_sms_channel?
@inbox.channel.is_a?(Channel::TwilioSms) && @inbox.channel.sms?
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def message_template_data
return [@inbox.channel.message_templates.presence || [], @inbox.channel.message_templates_last_updated, 'name'] unless @inbox.twilio_whatsapp?
[@inbox.channel.content_templates&.dig('templates') || [], @inbox.channel.content_templates_last_updated, 'friendly_name']
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end

View File

@@ -0,0 +1,16 @@
module Api::V1::Accounts::Concerns::InboxSecretManagement
extend ActiveSupport::Concern
def reset_secret
return head :not_found unless @inbox.api?
@inbox.channel.reset_secret!
end
def rotate_hmac_token
return head :not_found unless @inbox.web_widget? || @inbox.api?
@inbox.channel.regenerate_hmac_token
render :show
end
end

View File

@@ -1,55 +0,0 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end

View File

@@ -1,17 +1,46 @@
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
RESULTS_PER_PAGE = 25
# Scoped to a conversation, this returns it with the ones created around it: the in-thread
# navigation walks the whole history and never needs more than that.
def index
# Start with all conversations for this contact
@conversations = if params[:conversation_id].present?
conversation_with_neighbours(params[:conversation_id])
else
permitted_conversations.order(last_activity_at: :desc, id: :desc).limit(RESULTS_PER_PAGE)
end
end
private
def permitted_conversations
conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings
:assignee, :contact, :taggings, :contact_inbox
).preload(
inbox: :channel,
ai_assignee: { avatar_attachment: [:blob] }
).where(contact_id: @contact.id)
# Apply permission-based filtering using the existing service
conversations = Conversations::PermissionFilterService.new(
Conversations::PermissionFilterService.new(
conversations,
Current.user,
Current.account
).perform
end
@conversations = conversations.order(last_activity_at: :desc).limit(20)
def conversation_with_neighbours(display_id)
conversation = permitted_conversations.find_by!(display_id: display_id)
[neighbour(conversation, :older), conversation, neighbour(conversation, :newer)].compact
end
def neighbour(conversation, direction)
comparison = direction == :older ? '<' : '>'
order = direction == :older ? :desc : :asc
permitted_conversations
.where("(conversations.created_at, conversations.id) #{comparison} (?, ?)", conversation.created_at, conversation.id)
.order(created_at: order, id: order)
.first
end
end

View File

@@ -84,7 +84,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def create
ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url).merge(contact_type: :lead))
@contact.save!
@contact_inbox = build_contact_inbox
process_avatar_from_url
@@ -171,7 +171,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def permitted_params
params.permit(:name, :identifier, :email, :phone_number, :avatar, :blocked, :avatar_url, additional_attributes: {}, custom_attributes: {})
permitted_params = params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, :blocked,
additional_attributes: {}, custom_attributes: {})
return permitted_params unless Current.account.feature_enabled?('companies') && params.key?(:company_id)
company_id = params[:company_id]
permitted_params.merge(company_id: company_id.present? ? Current.account.companies.find(company_id).id : nil)
end
def contact_custom_attributes

View File

@@ -34,8 +34,11 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
end
def set_team
@team = Current.account.teams.find_by(id: params[:team_id])
@conversation.update!(team: @team)
team_id = params[:team_id].to_i
@team = team_id.positive? ? Current.account.teams.find(team_id) : nil
@conversation.with_lock do
@conversation.update!(team: @team)
end
render json: @team
end
@@ -43,3 +46,7 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
params[:assignee_type].to_s == 'AgentBot'
end
end
Api::V1::Accounts::Conversations::AssignmentsController.prepend_mod_with(
'Api::V1::Accounts::Conversations::AssignmentsController'
)

View File

@@ -0,0 +1,14 @@
class Api::V1::Accounts::Conversations::ContactInfoRequestsController < Api::V1::Accounts::Conversations::BaseController
rescue_from CustomExceptions::WhatsappContactInfoRequestError do |error|
render json: { error: error.message }, status: error.http_status
end
def show
render json: Whatsapp::ContactInfoRequestEligibilityService.new(conversation: @conversation).availability
end
def create
@message = Whatsapp::ContactInfoRequestService.new(conversation: @conversation, sender: Current.user).perform
render 'api/v1/accounts/conversations/messages/create'
end
end

View File

@@ -1,6 +1,19 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include DeviseTokenAuth::Concerns::SetUserByToken
include RequestExceptionHandler
include AccessTokenAuthHelper
include EnsureCurrentAccountHelper
include MfaEnforcementGuard
skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
around_action :handle_with_exception
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
before_action :authenticate_user!, unless: :authenticate_by_access_token?
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :check_account_mfa_enforcement, if: :authenticate_by_access_token?
before_action :conversation
def create
@@ -11,6 +24,12 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
private
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
end

View File

@@ -20,7 +20,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def destroy
ActiveRecord::Base.transaction do
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
# keeps the forward marker so replies to a deleted forward stay out of this conversation
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text,
content_attributes: message.content_attributes.slice('forwarded_message_id').merge(deleted: true))
message.attachments.destroy_all
end
end
@@ -28,10 +30,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def retry
return if message.blank?
service = Messages::StatusUpdateService.new(message, 'sent')
service.perform
message.update!(content_attributes: {})
::SendReplyJob.perform_later(message.id)
::SendReplyJob.perform_later(message.id) if claim_message_retry
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -52,6 +51,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
render json: { content: translated_content }
rescue Google::Cloud::Error => e
# `details` carries the clean human message; `message` includes gRPC debug noise
render_could_not_create_error(e.details.presence || e.message)
end
private
@@ -64,6 +66,21 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
@message_finder ||= MessageFinder.new(@conversation, params)
end
def claim_message_retry
message.with_lock do
next false unless message.failed?
Messages::StatusUpdateService.new(message, 'sent').perform
clear_source_id unless @conversation.inbox.api? || @conversation.inbox.web_widget?
true
end
end
def clear_source_id
Rails.logger.info "Cleared older source ID #{message.source_id} for message #{message.id}" if message.source_id.present?
message.update!(source_id: nil)
end
def permitted_params
params.permit(:id, :target_language, :status, :external_error)
end
@@ -78,3 +95,5 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
end
end
Api::V1::Accounts::Conversations::MessagesController.prepend_mod_with('Api::V1::Accounts::Conversations::MessagesController')

View File

@@ -1,41 +1,74 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
before_action :validate_participant_ids, only: [:create, :update]
def show
@participants = @conversation.conversation_participants
end
def create
participant_ids_to_add = participants_to_be_added_ids
ActiveRecord::Base.transaction do
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
@participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by!(user_id: user_id) }
end
notify_unread_count_change if participant_ids_to_add.any?
end
def update
participant_ids_to_add = participants_to_be_added_ids
participant_ids_to_remove = participants_to_be_removed_ids
changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
ActiveRecord::Base.transaction do
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by!(user_id: user_id) }
participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
@participants = @conversation.conversation_participants
notify_unread_count_change if changed_participant_ids.any?
@participants = @conversation.conversation_participants.reload
render action: 'show'
end
def destroy
participant_ids_to_remove = current_participant_ids & params[:user_ids]
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if participant_ids_to_remove.any?
head :ok
end
private
def participants_to_be_added_ids
params[:user_ids] - current_participant_ids
participant_ids - current_participant_ids
end
def participants_to_be_removed_ids
current_participant_ids - params[:user_ids]
current_participant_ids - participant_ids
end
def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end
def participant_ids
@participant_ids ||= params[:user_ids].map(&:to_i)
end
def validate_participant_ids
invalid_ids = participants_to_be_added_ids - @conversation.inbox.assignable_agents.map(&:id)
return if invalid_ids.empty?
render json: { error: 'Invalid participant IDs' }, status: :unprocessable_entity
end
def notify_unread_count_change
return unless Current.account.feature_enabled?('conversation_unread_counts')
return unless Current.account.feature_enabled?('unread_count_for_filters')
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
end
end

View File

@@ -0,0 +1,23 @@
class Api::V1::Accounts::Conversations::SuggestionsController < Api::V1::Accounts::Conversations::BaseController
FEATURE = 'captain_classifier'.freeze
before_action :ensure_feature_enabled
def labels
render json: { labels: classifier.labels }
end
def priority
render json: classifier.priority
end
private
def ensure_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(FEATURE)
end
def classifier
Captain::ConversationClassifierService.new(conversation: @conversation)
end
end

View File

@@ -2,12 +2,28 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
counts = if filtered_unread_counts_enabled?
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
else
unread_counts
end
render json: { payload: counts }
end
private
def unread_counts
::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
end
def filtered_unread_counts_enabled?
Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')

View File

@@ -2,9 +2,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include Events::Types
include DateRangeHelper
include HmacConcern
include ConversationCustomAttributesConcern
include ConversationAssignmentTargetsConcern
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :inbox, :contact, :contact_inbox, only: [:create]
before_action :validate_assignment_targets, :inbox, :contact, :contact_inbox, only: [:create]
ATTACHMENT_RESULTS_PER_PAGE = 100
@@ -80,7 +82,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def toggle_status
# FIXME: move this logic into a service object
if pending_to_open_by_bot?
if bot_handoff?
@conversation.bot_handoff!
elsif params[:status].present?
set_conversation_status
@@ -88,21 +90,17 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
else
@status = @conversation.toggle_status
end
assign_conversation if should_assign_conversation?
handle_human_open if @conversation.open? && Current.user.is_a?(User)
end
def pending_to_open_by_bot?
def bot_handoff?
return false unless Current.user.is_a?(AgentBot)
@conversation.status == 'pending' && params[:status] == 'open'
end
def should_assign_conversation?
@conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
end
def toggle_priority
@conversation.toggle_priority(params[:priority])
@conversation.toggle_priority(permitted_update_params[:priority])
head :ok
end
@@ -133,11 +131,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
update_last_seen_on_conversation(last_seen_at, true)
end
def custom_attributes
@conversation.custom_attributes = params.permit(custom_attributes: {})[:custom_attributes]
@conversation.save!
end
def destroy
authorize @conversation, :destroy?
::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform
@@ -148,6 +141,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def permitted_update_params
# TODO: Move the other conversation attributes to this method and remove specific endpoints for each attribute
raise ActionController::ParameterMissing, :priority unless params[:priority].nil? || Conversation.priorities.key?(params[:priority])
params.permit(:priority)
end
@@ -164,6 +159,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
end
def should_update_last_seen?
@@ -182,9 +178,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
def assign_conversation
@conversation.assignee = current_user
@conversation.save!
def handle_human_open
@conversation.with_lock do
@conversation.ai_assignee = nil
@conversation.assignee = Current.user if Current.user.agent?
@conversation.save!
end
end
def conversation
@@ -211,7 +210,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# fallback for the old case where we do look up only using source id
# In future we need to change this and make sure we do look up on combination of inbox_id and source_id
# and deprecate the support of passing only source_id as the param
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
lookup_scope = @inbox ? @inbox.contact_inboxes : ContactInbox.joins(:inbox).where(inboxes: { account_id: Current.account.id })
@contact_inbox ||= lookup_scope.find_by!(source_id: params[:source_id])
authorize @contact_inbox.inbox, :show?
rescue ActiveRecord::RecordNotUnique
render json: { error: 'source_id should be unique' }, status: :unprocessable_entity

View File

@@ -2,6 +2,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
before_action :check_authorization
before_action :fetch_custom_filters, only: [:index]
before_action :fetch_custom_filter, only: [:show, :update, :destroy]
before_action :validate_query_timezones, only: [:create, :update]
DEFAULT_FILTER_TYPE = 'conversation'.freeze
def index; end
@@ -47,6 +48,22 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
)
end
def validate_query_timezones
query = permitted_payload.fetch(:query, {})
return unless query.key?(:payload)
payload = query[:payload]
unless payload.is_a?(Array) && payload.all?(ActionController::Parameters)
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: 'payload')
end
payload.each do |condition|
::Filters::DateFilterHelper.timezone(condition[:timezone]) if condition.key?(:timezone)
end
rescue CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
def permitted_params
params.permit(:id, :filter_type)
end

View File

@@ -1,4 +1,5 @@
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_dashboard_apps, except: [:create]
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]

View File

@@ -0,0 +1,199 @@
require 'csv'
class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController
DATA_IMPORT_FEATURE = 'data_import'.freeze
before_action :ensure_data_import_feature_enabled
before_action :set_data_import, only: [:show, :start, :retry_import, :abandon, :error_logs, :skip_logs]
before_action :check_authorization
def index
@data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc)
data_import_ids = @data_imports.map(&:id)
@import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
@skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
end
def show
render_show
end
def validate_source
totals = source_class.credentials_validator(source_params: permitted_params.to_h, import_types: import_types).perform
render json: { valid: true, totals: totals }
rescue ArgumentError => e
render_source_validation_error(e.message)
rescue StandardError => e
render_source_client_error(e)
end
def create
@data_import = creation_service.perform
unless @data_import
render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity
return
end
enqueue_import(@data_import)
render_show
rescue ArgumentError => e
render_source_validation_error(e.message)
rescue StandardError => e
render_source_client_error(e)
end
def start
restart_service = DataImports::RestartService.new(account: Current.account, data_import: @data_import)
restart_result = restart_service.perform
@data_import = restart_service.data_import
if restart_result == :access_token_missing
render json: { message: "The #{source_name} #{credential_name} for this import is unavailable." }, status: :unprocessable_entity
return
end
enqueue_import(@data_import) if restart_result == :enqueue
render_show
end
def retry_import
retry_service = DataImports::RetryService.new(account: Current.account, data_import: @data_import)
retry_result = retry_service.perform
@data_import = retry_service.data_import
case retry_result
when :enqueue
enqueue_import(@data_import)
render_show
when :not_stalled
render json: { message: "This #{source_name} import is no longer stalled." }, status: :unprocessable_entity
when :active_import_exists
render json: { message: "Another #{source_name} import is already in progress." }, status: :unprocessable_entity
when :access_token_missing
render json: { message: "The #{source_name} #{credential_name} for this import is unavailable." }, status: :unprocessable_entity
end
end
def abandon
@data_import.abandon!
render_show
end
def skip_logs
send_data(
skip_logs_csv,
filename: "data-import-#{@data_import.id}-skip-logs.csv",
type: 'text/csv'
)
end
def error_logs
send_data(
error_logs_csv,
filename: "data-import-#{@data_import.id}-error-logs.csv",
type: 'text/csv'
)
end
private
def ensure_data_import_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE)
end
def set_data_import
@data_import = Current.account.data_imports.find(params[:id])
end
def check_authorization
authorize(@data_import || DataImport)
end
def permitted_params
params.permit(:name, :source_provider, :access_token, :domain, import_types: [])
end
def creation_service
DataImports::CreationService.new(
account: Current.account,
initiated_by: Current.user,
source_params: permitted_params.to_h
)
end
def import_types
return DataImports::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
Array(permitted_params[:import_types]).compact_blank
end
def source_class
provider = @data_import&.source_provider || permitted_params[:source_provider]
DataImports::Source.source_class(provider)
end
def source_name
return 'Integration' unless DataImports::Source.supported?(@data_import&.source_provider || permitted_params[:source_provider])
source_class::DISPLAY_NAME
end
def credential_name
return 'credential' unless DataImports::Source.supported?(@data_import&.source_provider || permitted_params[:source_provider])
source_class.credential_name
end
def enqueue_import(data_import)
DataImports::Source.source_class(data_import.source_provider).import_job_class.perform_later(
data_import,
data_import.active_import_run_id
)
end
def render_source_validation_error(message)
render json: { valid: false, message: message }, status: :unprocessable_entity
end
def render_source_client_error(error)
raise error unless source_class.client_error?(error)
message = if source_class.authentication_error?(error)
"We could not validate this #{source_name} #{credential_name}. Check the key and its permissions."
else
"#{source_name} could not be reached. Please try again."
end
render_source_validation_error(message)
end
def render_show
@import_errors_finder = DataImportErrorFinder.new(@data_import)
@skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params)
render :show
end
def skip_logs_csv
logs_csv(@data_import.import_errors.skip_logs)
end
def error_logs_csv
logs_csv(@data_import.import_errors.non_skip_logs)
end
def logs_csv(logs)
CSV.generate(headers: true) do |csv|
csv << %w[created_at kind source_object_type source_object_id error_code message details]
logs.order(:created_at).find_each do |log|
csv << [
log.created_at.iso8601,
log.details['kind'],
log.source_object_type,
log.source_object_id,
log.error_code,
log.message,
log.details.to_json
]
end
end
end
end

View File

@@ -2,11 +2,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
include Api::V1::Accounts::Concerns::InboxHealthManagement
include Api::V1::Accounts::Concerns::InboxSecretManagement
def index
@inboxes = policy_scope(Current.account.inboxes)
@@ -46,11 +46,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def update
inbox_params = permitted_params.except(:channel, :csat_config)
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
continue_update = false
ActiveRecord::Base.transaction do
continue_update = update_branded_email_layout
raise ActiveRecord::Rollback unless continue_update
inbox_params = permitted_params.except(:channel, :csat_config)
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
end
return unless continue_update
end
def agent_bot
@@ -68,12 +77,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
head :ok
end
def reset_secret
return head :not_found unless @inbox.api?
@inbox.channel.reset_secret!
end
def destroy
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
@@ -125,8 +128,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def reauthorize_and_update_channel(channel_attributes)
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
end
def update_channel_feature_flags
@@ -156,6 +159,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
formatted['template'] = config['template'] if config['template'].present?
end
def update_branded_email_layout
return true unless params.key?(:branded_email_layout)
branded_email_layout = normalized_branded_email_layout
unless Current.account.feature_enabled?(:branded_email_templates)
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email templates feature is not enabled')
return false
end
unless @inbox.email?
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email layout is only supported for email inboxes')
return false
end
@inbox.update_branded_email_layout!(branded_email_layout)
true
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
false
end
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,

View File

@@ -9,7 +9,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
redirect_uri: "#{base_url}/instagram/callback",
scope: REQUIRED_SCOPES.join(','),
enable_fb_login: '0',
force_authentication: '1',
force_reauth: 'true',
response_type: 'code',
state: generate_instagram_token(Current.account.id, params[:return_to])
}

View File

@@ -10,10 +10,17 @@ class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseC
private
def fetch_apps
@apps = Integrations::App.all.select { |app| app.active?(Current.account) }
@apps = Integrations::App.all.select do |app|
app.active?(Current.account) ||
(app.id == 'stripe' && Current.account_user&.administrator? && Current.account.hooks.exists?(app_id: 'stripe'))
end
end
def fetch_app
@app = Integrations::App.find(id: params[:id])
return unless %w[shopify stripe].include?(@app&.id)
# Keep direct lookups subject to the same availability checks as the integrations list.
raise ActiveRecord::RecordNotFound unless @app.active?(Current.account)
end
end

View File

@@ -0,0 +1,11 @@
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
private
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
def check_authorization
authorize(:hook)
end
end
Api::V1::Accounts::Integrations::BaseController.prepend_mod_with('Api::V1::Accounts::Integrations::BaseController')

View File

@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end
render_response(
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
)
end

View File

@@ -1,6 +1,8 @@
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_hook, except: [:create]
before_action :check_authorization
before_action :ensure_shopify_enabled, if: :shopify_hook?
before_action :reject_stripe_hook
def create
@hook = Current.account.hooks.create!(permitted_params)
@@ -31,15 +33,27 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
private
def fetch_hook
@hook = Current.account.hooks.find(params[:id])
def reject_stripe_hook
app_id = action_name == 'create' ? permitted_params[:app_id] : @hook.app_id
return unless app_id == 'stripe'
# Stripe credentials and connection changes are managed by its OAuth and disconnect endpoints.
head :not_found
end
def check_authorization
authorize(:hook)
def fetch_hook
@hook = Current.account.hooks.find(params[:id])
end
def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
end
def shopify_hook?
action_name == 'create' ? permitted_params[:app_id] == 'shopify' : @hook.app_id == 'shopify'
end
def ensure_shopify_enabled
head :not_found unless Shopify::FeatureGate.enabled?(account: Current.account)
end
end

View File

@@ -1,6 +1,7 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
revoke_linear_token
@@ -28,7 +29,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
end
def create_issue
issue = linear_processor_service.create_issue(permitted_params, Current.user)
issue = linear_processor_service.create_issue(permitted_params.to_h.stringify_keys, Current.user)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else

View File

@@ -1,5 +1,6 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
@hook.destroy!

View File

@@ -1,24 +1,27 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
include Shopify::IntegrationHelper
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :ensure_shopify_enabled
before_action -> { Shopify::ApiContext.setup! }, only: [:orders]
before_action :fetch_hook, except: [:auth, :complete_install]
before_action :check_authorization, only: [:auth, :complete_install, :destroy]
before_action :validate_contact, only: [:orders]
def auth
shop_domain = params[:shop_domain]
return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
unless shop_domain.is_a?(String) && Shopify::ShopDomain.valid?(shop_domain)
return render json: { error: 'Invalid Shopify shop domain' }, status: :unprocessable_entity
end
state = generate_shopify_token(Current.account.id)
raise 'Shopify OAuth is not configured' if client_id.blank? || state.blank?
auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
auth_url += URI.encode_www_form(
client_id: client_id,
scope: REQUIRED_SCOPES.join(','),
redirect_uri: redirect_uri,
state: state
)
render json: { redirect_url: auth_url }
oauth_client = OAuth2::Client.new(client_id, client_secret,
site: "https://#{Shopify::ShopDomain.normalize(shop_domain)}",
authorize_url: '/admin/oauth/authorize')
render json: { redirect_url: oauth_client.auth_code.authorize_url(
redirect_uri: "#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback",
scope: REQUIRED_SCOPES.join(','), state: state
) }
end
def orders
@@ -31,6 +34,25 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
render json: { error: e.message }, status: :unprocessable_entity
end
def complete_install
pending_installation = Shopify::PendingInstallation.claim(
token: params[:pending_install_token]
)
Shopify::InstallationService.new(account: Current.account, pending_installation: pending_installation).perform
head :ok
rescue Shopify::PendingInstallation::AlreadyClaimed => e
pending_installation&.release!
render json: { error: e.message }, status: :conflict
rescue Shopify::PendingInstallation::CommitOutcomeUnknown
raise
rescue Shopify::PendingInstallation::Error => e
pending_installation&.release!
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError
pending_installation&.release!
raise
end
def destroy
@hook.destroy!
head :ok
@@ -40,8 +62,8 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
private
def redirect_uri
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
def ensure_shopify_enabled
head :not_found unless Shopify::FeatureGate.enabled?(account: Current.account)
end
def contact
@@ -49,7 +71,9 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
end
def fetch_hook
@hook = Integrations::Hook.find_by!(account: Current.account, app_id: 'shopify')
hooks = Integrations::Hook.where(account: Current.account, app_id: 'shopify')
hooks = hooks.enabled if action_name == 'orders'
@hook = hooks.first!
end
def fetch_customers
@@ -81,25 +105,15 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
end
end
def setup_shopify_context
return if client_id.blank? || client_secret.blank?
ShopifyAPI::Context.setup(
api_key: client_id,
api_secret_key: client_secret,
api_version: '2025-01'.freeze,
scope: REQUIRED_SCOPES.join(','),
is_embedded: true,
is_private: false
)
end
def shopify_session
ShopifyAPI::Auth::Session.new(shop: @hook.reference_id, access_token: @hook.access_token)
end
def shopify_client
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session)
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(
session: shopify_session,
api_version: Shopify::ApiContext::API_VERSION
)
end
def validate_contact

View File

@@ -16,6 +16,8 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
end
def update
return update_message_mode if permitted_params[:message_mode].present?
@hook = channel_builder.update(permitted_params[:reference_id])
render json: { error: I18n.t('errors.slack.invalid_channel_id') }, status: :unprocessable_entity if @hook.blank?
end
@@ -35,7 +37,11 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
Integrations::Slack::ChannelBuilder.new(hook: @hook)
end
def update_message_mode
@hook.update!(settings: @hook.settings.merge('message_mode' => permitted_params[:message_mode]))
end
def permitted_params
params.permit(:reference_id)
params.permit(:reference_id, :message_mode)
end
end

Some files were not shown because too many files have changed in this diff Show More