6908 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