develop
6908 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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>
|
||
|
|
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. |
||
|
|
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 |
||
|
|
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**. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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>
|
||
|
|
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> |
||
|
|
351c96a213 | feat(companies): add Context.dev enrichment and redesign company pages (#16015) | ||
|
|
843385ff3a | fix: translate the conversation continuity email body (#16103) | ||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
991741ac14 | feat: feature announcements in the dashboard (#16082) | ||
|
|
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> |
||
|
|
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. |
||
|
|
c29e79ca00 | feat: collapse the reply box to give more room for reading (#16074) | ||
|
|
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. |
||
|
|
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> |
||
|
|
c7d000564e | feat: forward emails from email conversations (#15871) | ||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
e2de952fad | feat(reports): add monitor templates (#16030) | ||
|
|
178700dc2b | feat: add Captain conditions to automation rules (#16032) | ||
|
|
ac7ee4cac5 | feat: apply a macro to multiple conversations at once (#16027) | ||
|
|
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). |
||
|
|
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. |
||
|
|
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> |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
37164f874e | chore: add events for label and priority suggestions (#16028) | ||
|
|
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> |
||
|
|
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. |