Compare commits

...
Author SHA1 Message Date
Sony Mathew 4a9f961fa3 Merge branch 'codex/cw-7519-intercom-stalled-retry-15m' into codex/cw-7615-intercom-bulk-message-writes 2026-07-23 23:42:03 +05:30
Sony MathewandGitHub b6efdae243 perf: shorten Intercom import jobs (#15051)
## Description

Reduces sustained load during large Intercom imports by limiting
conversation list pages to 10 while retaining 50-contact pages. Cursor
and provider total-count behavior remain unchanged.

The one-minute progress heartbeat now lives in parent PR #15050 because
stalled-import detection must be safe when that PR is deployed
independently. This child PR therefore contains only the smaller-page
delta.

This is Phase 1, Task 2 of the [Intercom import optimization plan
(CW-7615)](https://linear.app/chatwoot/issue/CW-7615/optimize-intercom-import-reliability-and-bulk-message-ingestion).

This PR is stacked on #15050 and should be reviewed as the two-file
delta from `codex/cw-7519-intercom-stalled-retry-15m`.

## Closes

-
[CW-7615](https://linear.app/chatwoot/issue/CW-7615/optimize-intercom-import-reliability-and-bulk-message-ingestion)

## Type of change

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

## How to test

1. Start an Intercom import containing contacts and conversations across
multiple source pages.
2. Confirm contact list requests retain a page size of 50.
3. Confirm conversation list requests use a page size of 10.
4. Confirm page cursors and displayed provider totals continue advancing
normally.
5. Confirm the parent PR heartbeat keeps long conversations fresh while
these shorter pages are processed.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have added tests that prove the change is effective
- [x] New and existing focused tests pass locally with my changes
2026-07-23 23:38:01 +05:30
Sony Mathew 52a28db6d8 fix(imports): recheck final fallback ownership 2026-07-23 23:34:53 +05:30
Sony Mathew 0f439f2921 Merge branch 'codex/cw-7519-intercom-shorter-jobs-heartbeats' into codex/cw-7615-intercom-bulk-message-writes 2026-07-23 23:18:17 +05:30
Sony MathewandGitHub 9a489d38c7 chore: (refactor) prepare Intercom message batches (#15110)
## Description

Prepares each Intercom conversation's source message and parts as one
deterministic batch before persistence. The builder preserves provider
order, prefetches mappings and live messages once, and classifies every
entry as current-run, previous-run, stale-mapping repair,
existing-message repair, or new.

The importer consumes those prepared entries through the existing
individual transaction and retry path, so activity conversion, private
notes, sender attribution, metadata, timestamps, skip logs, idempotency,
indexing, and heartbeat behavior remain unchanged. This PR intentionally
does not bulk insert messages; batching writes is Phase 2 Task 2.

This is Phase 2, Task 1 (Task 4 overall) of
[CW-7615](https://linear.app/chatwoot/issue/CW-7615/optimize-intercom-import-reliability-and-bulk-message-ingestion).

This PR is stacked on #15052 and should be reviewed as the four-file
delta from `codex/cw-7615-intercom-query-timeout-retries`.

## Closes

- Tracking plan:
[CW-7615](https://linear.app/chatwoot/issue/CW-7615/optimize-intercom-import-reliability-and-bulk-message-ingestion)

## Type of change

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

## How to test

1. Import a conversation containing a source message, comments, notes,
activities, and entries with equal timestamps.
2. Retry the same run and verify messages and counters remain
idempotent.
3. Retry after a previous run and verify existing mappings are reported
as skips without changing their owner.
4. Delete a mapped Message, rerun, and verify the message and mapping
are repaired.
5. Delete only a mapping, rerun, and verify the existing Message is
reused without duplication.
6. Rerun a previously skipped part that now classifies as an activity
and verify it is repaired.
7. Import an empty conversation and verify no message or mapping
prefetch is performed.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-23 23:13:15 +05:30
Sony Mathew ed080ac253 fix(imports): validate message timestamp range 2026-07-23 23:07:51 +05:30
Sony MathewandGitHub 4e410b78ff perf: reduce Intercom import query pressure (#15052)
## Description

Reduces database pressure during large Intercom imports by deferring
same-run contact and message statistic recounts until the page boundary,
immediately before the cursor advances. Repeated mappings within a page
now mark their statistic group dirty instead of running aggregate counts
and persisting statistics for every record.

Contact and message database attempts now retry once when PostgreSQL
cancels a query. The failed transaction is allowed to roll back before
the importer waits for a randomized 200-500 ms delay and retries. A
recovered timeout creates no error; a second timeout follows the
existing item/message failure path exactly once. Provider, validation,
and other database errors are not retried.

This is Phase 1, Task 3 of the [Intercom import optimization plan
(CW-7615)](https://linear.app/chatwoot/issue/CW-7615/optimize-intercom-import-reliability-and-bulk-message-ingestion).

This PR is stacked on #15051 and should be reviewed as the two-file
delta from `codex/cw-7519-intercom-shorter-jobs-heartbeats`.

## Closes

- Tracking plan:
[CW-7615](https://linear.app/chatwoot/issue/CW-7615/optimize-intercom-import-reliability-and-bulk-message-ingestion)

## Type of change

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

## How to test

1. Retry a page whose contacts or messages already have same-run
mappings and confirm imported/skipped statistics are reconciled before
the page cursor advances.
2. Raise one `ActiveRecord::QueryCanceled` while persisting a contact
mapping and confirm the transaction rolls back, retries, and creates one
contact and mapping without an error.
3. Repeat for a message mapping and confirm one message and mapping are
created without an error.
4. Raise the timeout on both attempts and confirm one final item/message
error is recorded.
5. Raise an unrelated database error and confirm it is not retried.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have added tests that prove the change is effective
- [x] New and existing focused tests pass locally with my changes
2026-07-23 22:53:52 +05:30
Sony MathewandGitHub 580cbf91c5 fix: preserve Intercom bulk failure isolation (#15116)
This preserves per-message failure isolation when an Intercom bulk
message chunk cannot be committed. Query timeouts are retried once after
the failed transaction rolls back, while other database failures move
directly to an entry-by-entry fallback.

The fallback reacquires the import lock and refreshes each entry before
writing, so a competing worker cannot create duplicate messages. Each
individual write uses a savepoint, and search indexing happens only
after the import lock transaction commits.

This is Phase 2, Task 6 of CW-7615 and is stacked on #15111. The review
delta is one commit across the importer and its focused spec.

## Closes

-
[CW-7615](https://linear.app/chatwoot/issue/CW-7615/optimize-intercom-import-reliability-and-bulk-message-ingestion)

## How to test

1. Run an Intercom import where a bulk mapping write times out once and
confirm the chunk succeeds on retry.
2. Force a persistent timeout or database write failure and confirm
valid messages are imported through the fallback.
3. Force one fallback record to fail and confirm only that message is
recorded as an import error.
4. Simulate another worker repairing a message before fallback and
confirm no duplicate is created.
2026-07-23 22:52:06 +05:30
Sony Mathew d38ef6c9c8 fix(imports): harden bulk message recovery 2026-07-23 15:02:28 +05:30
Sony Mathew ae57547b84 fix(imports): reconcile skipped message stats on retry 2026-07-23 14:47:41 +05:30
Sony Mathew d6a5938916 style(imports): format prefetch fallback 2026-07-23 14:47:26 +05:30
Sony Mathew 5d479d36e1 Merge branch 'codex/cw-7615-intercom-message-batches' into codex/cw-7615-intercom-bulk-message-writes
# Conflicts:
#	app/services/data_imports/intercom/importer.rb
#	app/services/data_imports/intercom/message_batch_builder.rb
#	spec/services/data_imports/intercom/importer_spec.rb
2026-07-23 14:44:36 +05:30
Sony Mathew d238df2375 Merge branch 'codex/cw-7615-intercom-query-timeout-retries' into codex/cw-7615-intercom-message-batches
# Conflicts:
#	app/services/data_imports/intercom/importer.rb
2026-07-23 14:42:31 +05:30
Sony Mathew 21b83c6be8 Merge remote-tracking branch 'origin/codex/cw-7615-intercom-query-timeout-retries' into codex/cw-7615-intercom-query-timeout-retries
# Conflicts:
#	spec/services/data_imports/intercom/importer_spec.rb
2026-07-23 14:40:44 +05:30
Sony Mathew 03f720c0ef Merge branch 'codex/cw-7519-intercom-shorter-jobs-heartbeats' into codex/cw-7615-intercom-query-timeout-retries 2026-07-23 14:39:16 +05:30
Sony Mathew 2ae36d53c3 Merge branch 'codex/cw-7519-intercom-stalled-retry-15m' into codex/cw-7519-intercom-shorter-jobs-heartbeats 2026-07-23 14:38:52 +05:30
Sony Mathew ea783a89ea fix(imports): check run ownership before heartbeat 2026-07-23 14:38:23 +05:30
Sony Mathew 003cdbc417 fix(imports): guard Intercom mapped message type 2026-07-23 00:54:14 +05:30
Sony Mathew bee3eeae42 fix(imports): isolate Intercom prefetch failures 2026-07-23 00:53:31 +05:30
Sony Mathew 4f8215bc5c fix(imports): reload run state before heartbeat checks 2026-07-23 00:51:31 +05:30
Sony Mathew b5d4a5b6af fix(imports): preserve skipped stats on retry 2026-07-23 00:51:23 +05:30
Sony Mathew 25dd72da4e fix(imports): retry deferred stat reconciliation 2026-07-23 00:50:29 +05:30
Sony Mathew 5c9ffe4ce4 fix(imports): reconcile conversation stats on retry 2026-07-23 00:50:08 +05:30
Sony Mathew 0410211e23 Merge branch 'codex/cw-7615-intercom-message-batches' into codex/cw-7615-intercom-bulk-message-writes 2026-07-23 00:30:30 +05:30
Sony Mathew 1fdf128a74 Merge branch 'codex/cw-7615-intercom-query-timeout-retries' into codex/cw-7615-intercom-message-batches 2026-07-23 00:29:58 +05:30
Sony Mathew a3fda2a238 Merge branch 'codex/cw-7519-intercom-shorter-jobs-heartbeats' into codex/cw-7615-intercom-query-timeout-retries 2026-07-23 00:29:29 +05:30
Sony Mathew 045dab192c Merge branch 'codex/cw-7519-intercom-stalled-retry-15m' into codex/cw-7519-intercom-shorter-jobs-heartbeats 2026-07-23 00:29:01 +05:30
Sony Mathew b8945e8d87 fix(imports): guard stale run failures 2026-07-23 00:21:09 +05:30
Sony Mathew c8794e6e2d Merge branch 'codex/cw-7615-intercom-message-batches' into codex/cw-7615-intercom-bulk-message-writes 2026-07-23 00:05:54 +05:30
Sony Mathew fcf8972f25 Merge branch 'codex/cw-7615-intercom-query-timeout-retries' into codex/cw-7615-intercom-message-batches 2026-07-23 00:05:38 +05:30
Sony Mathew d7ee6eec9f Merge branch 'codex/cw-7519-intercom-shorter-jobs-heartbeats' into codex/cw-7615-intercom-query-timeout-retries 2026-07-23 00:05:10 +05:30
Sony Mathew 9156ccdaed Merge branch 'codex/cw-7519-intercom-stalled-retry-15m' into codex/cw-7519-intercom-shorter-jobs-heartbeats
# Conflicts:
#	app/services/data_imports/intercom/importer.rb
2026-07-23 00:03:23 +05:30
Sony Mathew 5b5cdf521a fix(imports): ignore stale detail polls after retry 2026-07-23 00:01:59 +05:30
Sony Mathew 7bdf38732f fix(imports): heartbeat active Intercom runs 2026-07-23 00:00:29 +05:30
Sony Mathew 6a43fd5b5a fix(imports): lock stalled import retries 2026-07-22 23:59:30 +05:30
Sony MathewandGitHub 35167631bb Merge branch 'develop' into codex/cw-7519-intercom-stalled-retry-15m 2026-07-22 23:48:36 +05:30
Shivam MishraandGitHub ddb0535a93 perf: reuse resolved count for reopen rate (#15122)
This improves the Captain overview by loading reporting metrics and FAQ
stats from separate endpoints. Range changes now refresh only the
metrics, while reopen-rate calculation reuses the resolved conversation
count to avoid redundant database queries.

## What changed

- Split Captain overview metrics and FAQ stats into separate APIs.
- Fetch FAQ stats independently from range-based metrics.
- Reuse resolved conversation totals when calculating reopen rate.
- Skip the reopen query when there are no resolved conversations.
2026-07-22 22:03:25 +05:30
Sivin VargheseandGitHub 42cbf7d3b9 fix: stray backslash after hard breaks before formatted list items (#15112) 2026-07-22 20:07:00 +05:30
887897ea98 fix: lock agent quota checks (#15029)
# Pull Request Template

## Description

Locks the agent quota check to the account row while creating account
users. This fixes a race where concurrent agent-create requests could
all observe the same remaining seat before any `account_users` row was
inserted.

The API continues to return the existing `402 Account limit exceeded.
Please purchase more licenses` response when the limit is reached. Bulk
create now preflights the requested email count while holding the
account lock, then creates each agent through the same locked builder
path. The Enterprise custom-role hook now no-ops when create did not
produce an agent.

Fixes:
[CW-7039](https://linear.app/chatwoot/issue/CW-7039/race-condition-in-agent-creation-bypasses-plan-agent-seat-limit)

## Type of change

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

## How Has This Been Tested?

- `POSTGRES_DATABASE=chatwoot_test_c20f_agent_quota REDIS_DB=9 bundle
exec rspec spec/builders/agent_builder_spec.rb
spec/enterprise/builders/agent_builder_spec.rb
spec/controllers/api/v1/accounts/agents_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb
spec/enterprise/controllers/enterprise/api/v1/accounts/agents_controller_spec.rb`
- `bundle exec rubocop app/builders/agent_builder.rb
app/controllers/api/v1/accounts/agents_controller.rb
enterprise/app/controllers/enterprise/api/v1/accounts/agents_controller.rb
spec/builders/agent_builder_spec.rb
spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb`
- `git diff --check`
- One-off threaded Rails validation with 8 concurrent `AgentBuilder`
calls against an account with one remaining seat: `created: 1`,
`limited: 7`, final `count=2`, `limit=2`.

## Checklist:

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

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-22 18:43:00 +05:30
8aee518149 fix(integrations): restrict Linear/Notion/Shopify hook deletion to admins (#15126)
Non-admin agents could delete an account's Linear, Notion, or Shopify
integration through the dedicated integration endpoints, which — unlike
the generic hooks endpoint — never checked the caller's role. This
restores the intended admin-only boundary for removing an integration.

## Closes
- https://linear.app/chatwoot/issue/CW-7383
- https://linear.app/chatwoot/issue/CW-7384
- https://linear.app/chatwoot/issue/CW-7189

## How to reproduce
As a non-admin **agent**, `DELETE
/api/v1/accounts/:id/integrations/{linear,notion,shopify}` returned
`200` and removed the account-wide integration. After this change it
returns `401` and the integration is preserved; administrators can still
remove it.

## What changed
- Route integration-hook deletion through `HookPolicy` (admin-only) via
a shared `Integrations::BaseController`, matching the generic hooks
controller.

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-07-22 17:53:25 +05:30
Vishnu NarayananandGitHub 5733b822e3 fix: guard widget email-transcript button against repeat clicks (#15095)
## Description

The widget email-transcript button (`ChatFooter.vue`) had no client-side
guard. Its visibility depends only on whether the contact has an email,
and the click handler fired a request on every click with no in-flight
lock, no disabled state, and no post-send handling. A user could
therefore trigger a large number of duplicate transcript emails from a
single conversation just by clicking repeatedly.

This adds a re-entry guard in the handler, disables the button while a
send is in flight, and applies a short cooldown (15s) after a successful
send. Normal use is unaffected: the button sends once, shows the success
toast, then briefly disables and automatically re-enables so a genuine
later re-request still works. On failure the button stays enabled so the
user can retry immediately. The cooldown timer is cleared on unmount.

Using a timed cooldown (rather than a permanent post-send lock) also
avoids the button getting stuck disabled if a resolved conversation is
reopened and later re-resolved.

This is the client-side complement to the server-side rate limit added
in #15085.

Fixes https://linear.app/chatwoot/issue/CW-7640
2026-07-22 17:36:36 +05:30
Sivin VargheseandGitHub 1e52d23d7a fix: guard agent sort against null names in assignment dropdown (#15125) 2026-07-22 15:27:04 +05:30
Sivin VargheseandGitHub fbb3479263 fix: guard agent sort against null names in assignment dropdown (#15125)
# Pull Request Template

## Description

This PR fixes a crash where opening a conversation threw `TypeError:
Cannot read properties of null (reading 'localeCompare')` and prevented
the agent assignment dropdown from rendering.

Since #14866, agent bots are included in the assignable agents list.
`AgentBot#name` is not presence-validated, so system bots (account-less,
global) can have a `null` name. Those nameless bots flowed into
name-based operations that assumed a string, causing crashes and
warnings across multiple surfaces:

* **Assignment dropdown sort:** `getAgentsByAvailability` called
`a.name.localeCompare(b.name)`, causing a `localeCompare` `TypeError`.
* **Dropdown search:** `MultiselectDropdownItems` called
`option.name.toLowerCase()`, causing a `toLowerCase` `TypeError`.
* **Agent Bots settings:** `Avatar` received `name=null` for a `String`
prop, triggering a Vue prop validation warning.

### What changed

* Keep nameless agent bots in the assignment dropdown and render a `-`
fallback label in `useAgentsList`. These are still valid,
assignable-by-ID records: the assignable agents API includes accessible
bots, and `Conversations::AssignmentService` assigns them by ID.
Preserving them avoids hiding valid assignment targets. Bots are still
included only when `includeAgentBots` is enabled.
* Make the sort in `getAgentsByAvailability` null-safe by coercing
missing names to an empty string (defense in depth).
* Make the search filter in `MultiselectDropdownItems` null-safe
(defense in depth).
* Pass a null-safe `name` prop to `Avatar` in the Agent Bots settings
list to eliminate the Vue prop validation warning.

Fixes
https://linear.app/chatwoot/issue/CW-7670/agent-assignment-dropdown-crashes-with-cannot-read-properties-of-null

## Type of change

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

## How Has This Been Tested?


1. Have a system agent bot (`name: null`) that is assignable to an
inbox.
2. Open any conversation in that inbox.
   * The agent assignment dropdown renders without console errors.
   * The nameless bot is listed with a `-` label and can be assigned.
3. Go to **Settings → Agent Bots**.
   * The page renders without the `Avatar` prop validation warning.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-22 15:23:19 +05:30
166a41c31c fix(whatsapp): prevent invalid automation sends outside reply window (#15113)
WhatsApp automations now fail locally when they attempt to send a
free-form message after the 24-hour customer service window has closed.
This avoids sending an invalid template request to Meta and gives users
a clear, actionable error instead of “Template not found or invalid
template name.”

Template messages continue to be sent whenever template parameters are
present. Free-form messages continue to be sent normally while the
conversation is replyable.

Fixes
https://linear.app/chatwoot/issue/PLA-183/prevent-whatsapp-automations-outside-the-24-hour-window-from-producing

### How to reproduce

1. Create a WhatsApp automation that sends a message without template
parameters.
2. Trigger it on a conversation whose 24-hour customer service window is
closed.
3. Observe that the message previously reached the template send path
and failed with a misleading provider error.

### How to test

1. Trigger an automation with template parameters and confirm it sends
as a template message.
2. Trigger an automation without template parameters inside the 24-hour
window and confirm it sends as a free-form message.
3. Trigger an automation without template parameters outside the 24-hour
window and confirm it fails locally with a clear error and makes no
request to Meta.

### Things to know

This changes only the invalid closed-window, no-template path. Existing
template and in-window message behavior remains unchanged.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-22 12:40:28 +04:00
e65e18e9c5 fix: normalize phone numbers during whatsapp channel lookup (#13709)
Fixes
https://linear.app/chatwoot/issue/PLA-99/whatsapp-messages-dropped-for-brazilargentina-numbers-due-to-phone
Fixes https://github.com/chatwoot/chatwoot/issues/14492

Meta's WhatsApp Cloud API includes `display_phone_number` in webhook
payloads, but its format can differ from the number stored in Chatwoot's
channel record.
In Brazil, Meta omits the mobile 9 prefix. For example, it sends
55419XXXXXXX (12 digits) instead of 554199XXXXXXX (13 digits). In
Argentina, Meta adds an extra 9 after the country code. For example, it
sends 549XXXXXXXXXX instead of 54XXXXXXXXXX.
The whatsapp event job uses `display_phone_number` for an exact-match
channel lookup. When the formats do not match, the lookup returns nil
and the incoming message is silently dropped, logging:

`Inactive WhatsApp channel: unknown - <phone_number>.`

The fix extends `get_channel_from_wb_payload` to fall back to normalized
phone number matching using the existing PhoneNumberNormalizationService
normalizers (Brazil, Argentina), which were previously only used for
contact-level lookups.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-22 10:33:40 +04:00
Shivam MishraandGitHub 89b83c65c8 fix: close message generation popover when its trigger scrolls away (#15114) 2026-07-21 19:34:57 +05:30
Sony MathewandGitHub 45278937bd Merge branch 'codex/cw-7615-intercom-query-timeout-retries' into codex/cw-7615-intercom-message-batches 2026-07-21 18:35:27 +05:30
Sony MathewandGitHub 114d7b5613 Merge branch 'codex/cw-7519-intercom-shorter-jobs-heartbeats' into codex/cw-7615-intercom-query-timeout-retries 2026-07-21 18:09:45 +05:30
Sony MathewandGitHub fbee7c2e23 Merge branch 'codex/cw-7519-intercom-stalled-retry-15m' into codex/cw-7519-intercom-shorter-jobs-heartbeats 2026-07-21 18:09:28 +05:30
Sony MathewandGitHub f866adf2c2 Merge branch 'develop' into codex/cw-7519-intercom-stalled-retry-15m 2026-07-21 18:09:06 +05:30
ed30ff9c22 fix(whatsapp): allow calling a contact with no existing conversation (#15014)
## Description

Agents can now place a WhatsApp call to a contact straight from the
contacts screen, even if that contact has never messaged in. Previously
the call only worked once a conversation already existed, so a freshly
added contact would fail with "Unable to start the call. Please try
again." — the only workaround was to get the contact to message the
channel first.


## Type of change

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

## How Has This Been Tested?

- Manually via UI

## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-21 18:08:32 +05:30
Sony Mathew 92d3536601 perf(imports): bulk write Intercom messages 2026-07-21 18:02:07 +05:30
Sony Mathew 901b234b4d refactor(imports): prepare Intercom message batches 2026-07-21 17:32:07 +05:30
7d2f01e402 feat(whatsapp): unify embedded signup feature gating (#15106)
WhatsApp embedded signup now uses
`whatsapp_embedded_signup_inbox_creation` as the single Chatwoot Cloud
rollout gate for inbox creation, proactive reconfiguration, and
disconnected inbox reauthorization. The authorization endpoint enforces
the same gate, so the UI and backend remain consistent.

Self-hosted installations keep their existing behavior.

## Things to know

- This reuses the existing feature flag; there is no migration or schema
change.
- The feature is shown as “WhatsApp Embedded Signup Flow” in feature
management.
- `whatsapp_reconfigure` remains visible and honored for self-hosted
proactive reconfiguration to preserve existing accounts. It can be
deprecated after the self-hosted dependency is removed or migrated.

## How to test

1. On Chatwoot Cloud, enable `whatsapp_embedded_signup_inbox_creation`
for an account.
2. Confirm that new WhatsApp inbox creation, proactive reconfiguration,
and disconnected inbox reauthorization are available.
3. Disable the flag and confirm those entry points are hidden and
authorization requests are rejected.
4. On self-hosted, confirm proactive reconfiguration remains controlled
by the existing `whatsapp_reconfigure` account setting.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-21 15:05:11 +04:00
Shivam MishraandGitHub 8c013415b8 fix: localize the captain overview summary greeting (#15108) 2026-07-21 16:29:08 +05:30
Tanmay Deep SharmaandGitHub 2144de92f2 fix(whatsapp): reopen conversation across a contact's coexistence identities (#15098)
WhatsApp contacts using coexistence are identified by more than one
source ID (a phone `wa_id` and a `BR.`/BSUID identity), so a single
contact ends up owning multiple `contact_inbox` records. The "reopen the
same conversation" feature scoped conversation reuse to a single
`contact_inbox`, so messages arriving under a different identity of the
same contact started a brand-new conversation — even with reopen enabled
— producing duplicate conversations.

This scopes reuse to the contact across all of its `contact_inbox`
records in the inbox instead of a single `contact_inbox`.

## Closes
- [CW-7651
](https://linear.app/chatwoot/issue/CW-7651/duplicate-conversations)

## How to reproduce
1. On a WhatsApp Cloud inbox with "reopen the same conversation" (lock
to single conversation) enabled.
2. Have a coexistence contact whose webhooks alternate between carrying
the phone `wa_id` and only the BSUID identity.
3. Before: each identity opens its own conversation → duplicates. After:
incoming messages reopen the contact's existing conversation regardless
of which identity the webhook carried.

## What changed
- `Whatsapp::IncomingMessageBaseService#set_conversation` now looks up
reusable conversations via `@contact.conversations.where(inbox_id:
@inbox.id)` instead of `@contact_inbox.conversations`.
- Updated existing specs to wire the conversation's `contact` to the
contact_inbox's contact, mirroring production data.
2026-07-21 16:21:40 +05:30
0e376f4fe2 feat(whatsapp-call): support BSUID callers for inbound voice calls (#14743)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-7276/bsuid-support-to-whatsapp-voice-calling

## Description

Keeps WhatsApp voice calls in the same thread as the chat when a caller
has adopted a **WhatsApp username** and hidden their phone number.
This makes the inbound-call path BSUID-aware, reusing the same
identifier the messaging pipeline keys on so calls land on the existing
`ContactInbox`/conversation.

## Type of change

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

## How Has This Been Tested?

-  Locally via UI

## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-21 16:19:53 +05:30
Shivam MishraandGitHub 67cab7171d feat: show Captain generation path on conversation messages [CW-7484] (#15078) 2026-07-21 15:15:13 +05:30
Shivam MishraandGitHub 7a5385cc32 feat: improve captain overview loading and reuse stats for summary [CW-7610] (#15105) 2026-07-21 15:14:18 +05:30
Sivin VargheseandGitHub 920a98ccf4 fix: calls dashboard load race (#15094) 2026-07-21 15:00:08 +05:30
Aakash BakhleandGitHub ae49af354d fix: serialize multimodal Captain session content (#15096)
Captain now saves agent session records when a user message includes an
image. The saved record keeps the image URL and excludes downloaded
image bytes, so image replies no longer report a JSON serialization
error after delivery.

Fixes:
https://chatwoot-p3.sentry.io/issues/7618423184/?alert_rule_id=13673680&alert_type=issue&notification_uuid=d22a7ab9-95d6-4bba-85e0-733a28466775&project=6382945

## Root cause

RubyLLM downloads image attachments and caches the binary bytes inside
`RubyLLM::Content`. `SessionCaptureService` passed the live object to
the `run_context` JSON column. Rails then tried to encode the cached
JPEG bytes as UTF-8 and raised `JSON::GeneratorError`.

The error did not block replies, handoffs, or credit updates because
session capture rescues its own failures. The failed write meant that
Chatwoot lost the agent session record for the response.

## How to reproduce

1. Send an image to a Captain V2 assistant.
2. Let RubyLLM load the image during the model request.
3. Save the resulting conversation history in an agent session.
4. Observe the JSON encoding error when Rails reaches the cached image
bytes.

## What changed

`SessionCaptureService` now converts `RubyLLM::Content` to its JSON safe
hash before saving the current turn. The hash contains the message text
and attachment URL without the cached bytes. Other message content is
unchanged.

The focused service spec covers a cached JPEG byte payload and passes
with 12 examples. RuboCop reports no offenses in the changed service and
spec.
2026-07-21 13:04:12 +05:30
d1fa8d8c2f refactor: share whatsapp/twilio template logic via @chatwoot/utils (#15001)
# Pull Request Template

## Description
Moves the WhatsApp & Twilio content-template logic to the shared
[`@chatwoot/utils`](https://github.com/chatwoot/utils)
([PR](https://github.com/chatwoot/utils/pull/62)) package so web and
mobile share one implementation. The neutral core takes the raw template
and returns `processed_params`, the same shape the web parsers already
use, so it's a drop-in with no behavior change.

- `templateHelper.js` / `URLHelper.js` → source `MEDIA_FORMATS`,
`findComponentByType`, `processVariable`, `buildTemplateParameters`,
`extractFilenameFromUrl` from the package
- `inboxes.js` → filters with shared `isSendableTemplate`
- `WhatsAppTemplateParser.vue` / `ContentTemplateParser.vue` →
`isFormInvalid` and Twilio media helpers now use the shared
`isWhatsAppComplete` / `isTwilioComplete` / `applyTwilioMediaFilename`

> Depends on the `@chatwoot/utils` release adding the shared template
API, bump `package.json` from `^0.0.55` to the published version before
merge.

Fixes
[CW-7540](https://linear.app/chatwoot/issue/CW-7540/web-templates-integration-with-utils)

## Type of change

- [x] Breaking change (Refactor)

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-21 10:08:23 +04:00
71fffdd2b9 fix: rate limit widget conversation transcript API (#15085)
## Description

The widget conversation transcript endpoint (`POST
/api/v1/widget/conversations/transcript`) has no rate limit. Every other
comparable endpoint does: the agent-facing transcript API and the widget
conversation-create and contact-update endpoints are all throttled. This
gap lets a single client trigger a large burst of transcript emails from
one conversation.

This adds an IP-based throttle (5 requests/hour) for the endpoint,
placed inside the existing widget-API throttle block so it inherits the
`ENABLE_RACK_ATTACK_WIDGET_API` opt-out used by embedded/iframe clients.
The limit is generous for legitimate use (a visitor emailing themselves
a transcript) while stopping abusive loops. Throttled requests get the
standard 429 the widget already handles.

## Type of change

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

## How Has This Been Tested?

`config/initializers/rack_attack.rb` throttles have no existing specs in
this file, so this follows the established convention (no new spec).
Verified `ruby -c` and `rubocop` pass on the file. The new throttle
mirrors the sibling widget throttles directly above it (same IP key,
path guard, and structure).

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-20 16:09:29 -07:00
Sojan JoseandGitHub eae9841eb4 fix: restore token access to account APIs (#15088)
Token-authenticated requests to Agent Bots, Labels, and affected Captain
endpoints return normal responses again. The regression was caused by
duplicate `current_account` callbacks in subclasses moving account
resolution behind the API entitlement check, leaving `Current.account`
unset.

## Closes

- https://linear.app/chatwoot/issue/CW-7641/5xx-errors-in-agent-bot-apis

## How to reproduce

1. Send `GET /api/v1/accounts/:account_id/agent_bots` with a valid
administrator API access token.
2. Observe a `500` from `validate_token_api_access` because
`Current.account` is `nil`.
3. With this change, account resolution runs in the base-controller
order and the request succeeds.

## What changed

- Removed redundant `current_account` callbacks from account-scoped
controllers that already inherit the callback from
`Api::V1::Accounts::BaseController`.
- Kept the standalone direct-upload controller callback unchanged.
- Added regression coverage for administrator API-token access to Agent
Bots.
2026-07-20 15:28:33 -07:00
160732c07d fix: rate limit agent management APIs (#15081)
# Pull Request Template


Bring  agent create and delete requests under rack attack throttling

Related to https://linear.app/chatwoot/issue/CW-7637


Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-07-20 20:41:39 +05:30
Sivin VargheseandGitHub 7a299307b8 fix: apply installation name to sender name preview (#15076) 2026-07-20 19:28:58 +05:30
Sivin VargheseandGitHub 08f49f5896 fix: prevent channel list crash on hard reload (#15074) 2026-07-20 19:28:48 +05:30
Sivin VargheseandGitHub bf0a10c780 feat: introduce voice call dashboard (#14954) 2026-07-20 14:54:05 +05:30
Sojan Jose a752e56765 Merge branch 'release/4.16.0' into develop 2026-07-18 03:59:29 -07:00
Sojan Jose ff6c068743 Bump version to 4.16.0 2026-07-18 03:54:55 -07:00
Sojan JoseandGitHub 465763f256 feat: enable Uzbek language (#15056)
Enables Uzbek as a selectable Chatwoot language and wires the `uz`
locale into the dashboard, widget, and survey translation loaders.

## Closes

Closes https://github.com/chatwoot/chatwoot/issues/15030

## Why

Uzbek is now available in the Chatwoot Crowdin project. The widget
translation has been proofread and is 100% translated and approved.

## What changed

- Adds Uzbek to the supported language registry
- Registers the `uz` locale with the dashboard, widget, and survey i18n
loaders

## Validation

- Proofread the Uzbek widget translation in Crowdin
- Verified placeholder preservation and corrected wording where needed
- Confirmed the widget file is 100% translated and 100% approved

This draft intentionally remains blocked until the Crowdin sync adds the
generated Uzbek locale files. It should only be marked ready and merged
after that sync lands.
2026-07-18 03:40:53 -07:00
e70bbfaaa5 chore: Update translations (#15059)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-18 02:59:24 -07:00
Sojan JoseandGitHub a61a8bfbc6 chore(deps): address bundle audit vulnerabilities (#15055)
Updates the vulnerable Ruby dependencies reported by bundle audit so the
application resolves to patched versions.

## Why

The latest Ruby advisory database reports known vulnerabilities in
Datadog, Loofah, and Rails HTML Sanitizer on `develop`, including a
high-severity denial-of-service advisory in Datadog.

## What changed

- Updates `datadog` from 2.19.0 to 2.38.0
- Updates `loofah` from 2.23.1 to 2.25.2
- Updates `rails-html-sanitizer` from 1.6.1 to 1.7.1
- Refreshes Datadog's required native packages

## Validation

Verified the resolved bundle is satisfied, bundle audit reports no known
vulnerabilities, and Datadog 2.38.0 loads successfully.
2026-07-17 16:04:56 -07:00
PranavandGitHub 4cb89d0de1 chore: Update brand assets (#15054)
Refresh favicons and app icons from the official brand kit and align the
PWA theme colors with the current brand blue.
2026-07-17 13:56:39 -07:00
Sony MathewandGitHub 259187e1fd docs: add branded email layout API reference (#15023)
## Description

Adds Swagger API documentation for branded email layouts, including the
account-level layout endpoint, Email inbox update field, and generated
API schemas. This stacked PR keeps the implementation review in #14936
focused on the product change.

Related:
[CW-7514](https://linear.app/chatwoot/issue/CW-7514/branded-html-email-templates-per-inboxbrand)
Depends on #14936

## Type of change

- [x] This change requires a documentation update

## How to test

1. Run `bundle exec rake swagger:build`.
2. Start Chatwoot locally and open `http://localhost:3000/swagger`.
3. Verify the branded email layout account endpoint and Email inbox
`branded_email_layout` field appear in the API reference.

## Checklist

- [x] I have performed a self-review of my changes
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
2026-07-17 16:20:00 +05:30
90861f8809 feat(whatsapp): gate embedded signup inbox creation (#15046)
WhatsApp inbox creation now shows Embedded Signup for Chatwoot Cloud
accounts only when the new `whatsapp_embedded_signup_inbox_creation`
feature flag is enabled. Cloud accounts without the flag go directly to
manual WhatsApp Cloud API setup, while self-hosted installations with a
configured WhatsApp App ID retain their existing Embedded Signup flow.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-17 14:09:36 +04:00
Sony Mathew 1c16c427f4 perf(imports): reduce Intercom query pressure 2026-07-17 15:36:17 +05:30
Sony MathewandGitHub 0b5881889a Merge branch 'develop' into codex/cw-7519-intercom-stalled-retry-15m 2026-07-17 15:27:31 +05:30
Sony MathewandGitHub c420edce58 feat: branded email layouts for email inboxes (#14936)
## Description

Adds an API-only branded email layout feature for Email inbox replies.
Administrators can configure an account-level fallback layout and
per-email-inbox overrides with Liquid HTML using `{{ content_for_layout
}}`, and eligible outbound email replies/transcripts render through the
scoped layout when the account feature flag `branded_email_templates` is
enabled.

The feature is disabled by default and is manually controlled through
the normal account feature flag mechanism.

Fixes
https://linear.app/chatwoot/issue/CW-7514/branded-html-email-templates-per-inboxbrand

## Type of change

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

## How to test

1. Start Chatwoot locally and sign in as an administrator.
2. Enable the account feature flag for the account you are testing:

   ```ruby
   account = Account.find(<account_id>)
   account.enable_features!(:branded_email_templates)
   ```

3. Create or pick an Email inbox, then note the `account_id` and
`inbox_id`.
4. Configure an account-level fallback layout through the API using
authenticated admin headers:

   ```http
   PATCH /api/v1/accounts/:account_id/branded_email_layout
   Content-Type: application/json

   {
"branded_email_layout": "<html><body><header>Account Brand</header>{{
content_for_layout }}<footer>Account footer</footer></body></html>"
   }
   ```

5. Confirm `GET /api/v1/accounts/:account_id/branded_email_layout`
returns the saved account layout.
6. Configure an inbox-level override for the Email inbox:

   ```http
   PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id
   Content-Type: application/json

   {
"branded_email_layout": "<html><body><header>Inbox Brand</header>{{
content_for_layout }}<footer>Inbox footer</footer></body></html>"
   }
   ```

7. Confirm `GET /api/v1/accounts/:account_id/inboxes/:inbox_id` returns
the inbox `branded_email_layout`.
8. Send an Email inbox reply and verify the outbound email body is
wrapped with the inbox layout around the generated reply content.
9. Clear the inbox layout by sending a blank value, then send another
reply and verify it falls back to the account layout:

   ```http
   PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id
   Content-Type: application/json

   {
     "branded_email_layout": ""
   }
   ```

10. Clear the account layout with a blank value and verify Email replies
return to the existing no-layout behavior.
11. Verify validation behavior:
- Updating either API with a layout that omits `{{ content_for_layout
}}` returns `422`.
    - Updating either API with invalid Liquid returns `422`.
- Updating a non-Email inbox with `branded_email_layout` returns `422`.
- Disabling `branded_email_templates` and updating a layout returns
`422`.

## How Has This Been Tested?

Validation:

- `bundle exec rspec spec/models/email_template_spec.rb
spec/controllers/api/v1/accounts/branded_email_layouts_controller_spec.rb
spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
spec/lib/email_templates/db_resolver_service_spec.rb
spec/mailers/conversation_reply_mailer_spec.rb`
- `bundle exec rspec
spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb`
- `bundle exec rubocop` on changed Ruby files, excluding generated
`db/schema.rb`
- `git diff --check` and `git diff --cached --check`
- YAML parsing for changed config/Swagger files
- `bundle exec rails routes -g branded_email_layout`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have made corresponding changes to the documentation
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] My changes generate no new warnings
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-17 15:25:55 +05:30
Sony Mathew c58ba2f9c2 perf(imports): shorten Intercom jobs and add heartbeats 2026-07-17 14:49:58 +05:30
Sivin VargheseandGitHub 331875cdaa feat: Add popular content per locale (#14939) 2026-07-17 14:43:42 +05:30
Sony Mathew 2decd8c544 feat(imports): retry stalled Intercom imports 2026-07-17 14:39:35 +05:30
Sivin VargheseandGitHub 5af26e45a9 chore: Cleanup help center layouts (#14812) 2026-07-17 14:07:52 +05:30
f948b0b8d9 test(onboarding): fix inbox channel dialog spec (#15048)
Restores the Inbox Channels dialog’s Facebook-gating test coverage by
isolating account feature dependencies that are unrelated to these
cases. This keeps the onboarding checks focused on whether Facebook is
configured and avoids initializing router and account-store state.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-17 13:57:34 +05:30
081e08c7c6 fix(whatsapp): skip template sync for suspended accounts (#15047)
Suspended accounts no longer participate in scheduled WhatsApp template
syncs. This avoids unnecessary external API calls while keeping the
existing refresh behavior unchanged for active accounts.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-17 11:57:07 +04:00
Sivin VargheseandGitHub dc3a4814aa fix: resolve {{conversation.id}} to display_id (#15041) 2026-07-17 12:55:48 +05:30
5e811eab99 chore(inbox): re-enable Instagram inbox creation (#15042)
Instagram inbox creation is available again on Chatwoot Cloud. Users can
discover and connect Instagram during onboarding or from Add Inbox,
while WhatsApp restrictions and existing-inbox Instagram advisories
remain unchanged.

Closes https://linear.app/chatwoot/issue/CW-7549/enable-instagram

## How to test

1. On Chatwoot Cloud, open Add Inbox and confirm Instagram can be
selected.
2. Confirm **Continue with Instagram** is enabled and starts the OAuth
flow.
3. In onboarding, confirm Instagram is displayed and can start OAuth.
4. Confirm WhatsApp embedded signup remains restricted.

## What changed

- Removed the Cloud-only Instagram filter and OAuth guard from
onboarding.
- Re-enabled the regular Instagram inbox creation action on Cloud.
- Removed the obsolete “Instagram inbox creation is temporarily
unavailable” copy.
- Updated the onboarding expectation for Chatwoot Cloud.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-16 20:28:49 +04:00
Shivam MishraandGitHub 9749a3dc96 feat: capture captain sessions for v2 assistant responses [CW-7485] (#14971)
Records a `Captain::Session` row for every Captain V2 assistant response
delivered in a conversation, so we can show how a response was generated
and report on credit, FAQ, and document usage. Stacked on #14970 (the
`captain_sessions` model).

## What changed

- `FaqLookupTool` now records the retrieved FAQ ids (and their backing
document ids) into the shared run state, accumulated across tool calls.
- `AgentRunnerService` exposes the raw ai-agents run result via
`last_run_result`; the `generate_response` return shape is unchanged, so
the playground path is unaffected.
- New `Captain::Assistant::SessionCaptureService` builds the session:
scenario resolved from the answering agent name, model from
`assistant.agent_model`, token usage plus the trimmed current-turn
conversation history stored in `run_context`.
- `ResponseBuilderJob` captures after delivery: `credits_consumed`
mirrors the actual charge (1.0 for a billed response, 0.0 for handoffs,
where the session points at the customer-facing handoff message).
Capture runs outside the delivery transaction and swallows its own
failures, so a logging bug can never block or roll back a customer
reply.

V1 responses and copilot are out of scope; copilot capture comes next.

## How to test

On an account with `captain_integration_v2` enabled and an inbox
connected to an assistant with approved FAQs, send a customer message on
a pending conversation. After the assistant replies, a
`Captain::Session` row should exist with the conversation as subject,
the reply message as result, the FAQs/documents used, and the run
context for that turn. Asking for a human agent should produce a
zero-credit session pointing at the handoff message.

<img width="2428" height="1058" alt="CleanShot 2026-07-15 at 17 25
40@2x"
src="https://github.com/user-attachments/assets/d8e44923-c17b-494f-8c33-c8fa4219438c"
/>
2026-07-16 18:20:44 +05:30
Shivam MishraandGitHub 8dd0d08322 refactor: align conversation direct uploads with standard account auth (#15039)
Conversation attachment uploads now go through the same authentication
that every other account-scoped API endpoint uses. Agents continue to
attach files exactly as before, and the upload request is now tied to
the agent's dashboard session instead of a separately serialized access
token.

Because the upload request is now authenticated, the dashboard proves
the agent's session directly instead of passing
`currentUser.access_token`. This keeps uploads working alongside the
profile access-token changes in #14973, including on accounts where that
token is serialized as empty.

## What changed

- `Api::V1::Accounts::Conversations::DirectUploadsController` now runs
the standard account auth stack: API access token when the
`api_access_token` header is present, dashboard session
(devise-token-auth) otherwise, with agent-bot tokens rejected.
Previously it inherited `ActiveStorage::DirectUploadsController`
directly and did not run any authentication.
- `EnsureCurrentAccountHelper#ensure_current_account` now returns `401`
when a request has neither an authenticated user nor a bot resource,
instead of continuing. This closes the same gap for any controller that
relies on the helper.
- The dashboard direct-upload paths (`useFileUpload.js` and the legacy
`fileUploadMixin.js`) now attach the agent's session headers to the
upload request via a new `directUploadsHelper.js`, instead of sending
`currentUser.access_token`.

## How to test

1. As a logged-in agent, open a conversation and attach a file. Upload
should succeed as before, on installs with direct uploads enabled.
2. Confirm attachments still work for an agent on an account whose
profile access token is not serialized (e.g. a Cloud plan without
`api_and_webhooks`).
3. Send a `POST` to
`/api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads`
with no credentials, an empty `api_access_token`, or an invalid token,
and confirm it returns `401`.
4. Confirm a valid agent of the account (via API token or session) gets
`200`, while an agent of a different account gets `401`.
2026-07-16 18:17:29 +05:30
7e88d44fd9 fix(whatsapp): update manual migration guide link (#15040)
Updates the WhatsApp manual migration guide links in the inbox banner
and migration dialog to use `https://chwt.app/migrate-whatsapp`.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-16 15:57:37 +04:00
Tanmay Deep SharmaandGitHub 2891a72cb9 feat(whatsapp): enable reconfigure for embedded signup inboxes (#15038) 2026-07-16 16:18:58 +05:30
522e3c4d3f feat: enforce api_and_webhooks feature for token API and account webhooks (#14973)
This gates API-token access and outgoing account webhooks behind the
`api_and_webhooks` account feature introduced in #14972. On Chatwoot
Cloud, Hacker accounts lose token-authenticated account API access and
account webhook delivery, while paid accounts retain them through the
billing-plan feature reconcile. Community and self-hosted installations
continue to work without any upgrade-time interruption.

## What changed

- Added `Account#api_and_webhooks_enabled?` as the single backend kill
switch. Core returns enabled; the Enterprise override consults the
account flag on Chatwoot Cloud and remains enabled off-Cloud.
- Account-scoped v1 and v2 requests authenticated with a user or
agent-bot API token now return `403 Forbidden` when the feature is
disabled. Invalid tokens still return 401, and dashboard session
requests are unaffected.
- Profile responses return an empty access token when none of the user's
accounts has access. The stored token is preserved, and the profile UI
disables its token controls with paid-plan copy on Cloud.
- Account webhook delivery stops when the feature is disabled. Webhook
CRUD remains available to session-authenticated dashboard requests,
API-inbox webhooks continue to be delivered, and the Cloud dashboard
shows a webhook paywall instead of the webhook list.
- Removed the database backfill migration. Existing paid Cloud accounts
should be enabled with the one-off script below before enforcement is
deployed.

## Existing paid-account rollout

Run this as an ad-hoc Rails runner script on Chatwoot Cloud. It
intentionally targets only the Startups, Business, and Enterprise plans
and does not add `api_and_webhooks` to `manually_managed_features`, so
future billing reconciles remain authoritative.

```rb
paid_plan_names = %w[Startups Business Enterprise]
accounts = Account.where("custom_attributes ->> 'plan_name' IN (?)", paid_plan_names)

total = accounts.count
enabled = 0
skipped = 0

puts "Enabling api_and_webhooks for #{total} paid account(s)..."

accounts.find_each(batch_size: 500).with_index(1) do |account, processed|
  if account.feature_enabled?('api_and_webhooks')
    skipped += 1
  else
    account.enable_features!('api_and_webhooks')
    enabled += 1
  end

  puts "Processed #{processed}/#{total}..." if (processed % 1000).zero?
end

puts "Done! Enabled: #{enabled}, Skipped: #{skipped}, Total: #{total}"
```

For example, save the snippet outside the repository as
`enable_api_and_webhooks.rb`, then run:

```sh
bundle exec rails runner /path/to/enable_api_and_webhooks.rb
```

## How to test

- On Cloud, use a Hacker account and confirm token-authenticated
requests to account-scoped v1 and v2 endpoints return 403, while the
same dashboard actions continue to work through session authentication.
- Confirm profile access-token controls are disabled with paid-plan copy
when all accounts are ineligible, and remain available when at least one
account has the feature.
- Confirm the Webhooks settings page shows the billing paywall for a
Cloud account without the feature; admins get the billing action and
agents get the existing ask-an-admin message.
- Confirm outgoing account webhooks stop for an ineligible Cloud account
while API-inbox webhooks still deliver.
- Confirm community and self-hosted installations retain API and webhook
behavior after upgrading, even when an existing account does not have
the stored feature bit.


### Screenshots

## Cloud

<img width="2590" height="642" alt="CleanShot 2026-07-15 at 15 13 14@2x"
src="https://github.com/user-attachments/assets/431a7bd8-1742-4e7a-b312-d3ad92015f9b"
/>

<img width="2152" height="994" alt="CleanShot 2026-07-15 at 15 14 37@2x"
src="https://github.com/user-attachments/assets/475dda48-d1c5-4be5-a3c3-7a96b9713724"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-16 13:43:49 +04:00
0a25a0ef66 fix(whatsapp): log manual transfer only for embedded signup migrations (#15032)
The `[WHATSAPP_MANUAL_TRANSFER] success` log introduced in #14975 to
track embedded signup → manual migrations was firing on any WhatsApp
credential change — including routine `api_key` rotations on inboxes
that were already manually configured — inflating the migration count.
The log now fires only for the actual migration, and uses a new tag so
log searches don't match the older over-counted entries.

## How to reproduce

1. On a manually configured WhatsApp Cloud inbox, update the API key
from inbox settings → Configuration.
2. Before this change, the app log records a `[WHATSAPP_MANUAL_TRANSFER]
success` line even though no migration happened; after this change it
stays silent.
3. Switching an embedded signup inbox to manual setup still logs the
migration (now as `[WHATSAPP_EMBEDDED_TO_MANUAL] success`).

## What changed

- `Channel::Whatsapp#log_credentials_transfer` now keys off the
migration's unique signal — `provider_config['source']` changing from
`embedded_signup` to absent — instead of diffing credential keys.
- Renamed the log tag from `WHATSAPP_MANUAL_TRANSFER` to
`WHATSAPP_EMBEDDED_TO_MANUAL` (success and failure lines) so the
corrected entries are searchable without matching pre-fix false
positives.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-16 13:35:20 +04:00
Sivin VargheseandGitHub 0fccb7dacd feat: stage edits to published articles as drafts (#14842) 2026-07-16 12:44:10 +05:30
PranavandGitHub f1a07657e9 fix(whatsapp): restore embedded signup reauthorization banner (#15035)
Show the reauthorization banner whenever an embedded signup inbox has a
reauthorization error instead of suppressing it when the manual
migration flow is enabled. Reword the manual migration banner and dialog
as a recommendation to reconnect with your own Meta app, and switch
their styling from amber warning to blue info.
2026-07-16 12:28:30 +05:30
9cb6c501b5 fix: html/body background not applied in appearance mode (#13989)
# Pull Request Template

## Description

This PR fixes the white background bleed visible in the widget, article
viewer, and Help Center when dark mode is active.
This change was previously merged but later reverted due to a background
being added for the unread bubble.

Reverted PR: https://github.com/chatwoot/chatwoot/pull/13955,
https://github.com/chatwoot/chatwoot/pull/13981

**What was happening**

While scrolling, the `<body>` element retained a white background in
dark mode. This occurred because dark mode classes were only applied to
inner container elements, not the root.

**What changed**

* **Widget:** Updated the `useDarkMode` composable to sync the `dark`
class to `<html>` using `watchEffect`, allowing `<body>` to inherit dark
theme variables. Also added background styles to `html`, `body`, and
`#app` in `woot.scss`.
* **Help center portal:** Moved `bg-white dark:bg-slate-900` from
`<main>` to `<body>` in the portal layout so the entire page background
responds correctly to dark mode, including within the widget iframe.
* **ArticleViewer:** Replaced hardcoded `bg-white` with `bg-n-solid-1`
to ensure better theming.


Fixes
https://linear.app/chatwoot/issue/CW-6704/widget-body-colour-not-implemented

## Type of change

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

## How Has This Been Tested?

### Screencasts

### Before

**Widget**


https://github.com/user-attachments/assets/e0224ad1-81a6-440a-a824-e115fb806728

**Help center**


https://github.com/user-attachments/assets/40a8ded5-5360-474d-9ec5-fd23e037c845



### After

**Widget**


https://github.com/user-attachments/assets/dd37cc68-99fc-4d60-b2ae-cf41f9d4d38c

<img width="347" height="265" alt="image"
src="https://github.com/user-attachments/assets/89460b36-c8ed-4579-9737-c258c6fe5da5"
/>


**Help center**


https://github.com/user-attachments/assets/bc998c4e-ef77-46fa-ac7f-4ea16d912ce3




## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-16 12:21:01 +05:30
Vishnu NarayananandGitHub ab01ab7853 fix(perf): back off conversation meta polling when the count is unknown (#15031)
## Description

The conversation sidebar counts (`GET /conversations/meta`) are polled
on a debounced cadence selected from the last known conversation count
(`allCount` in the `conversationStats` store).

`allCount` starts at `0` and is only set after a **successful** meta
response. When the meta query is slow or failing under load, `allCount`
stays `0`, which selected the fastest cadence, so every client polled as
aggressively as possible exactly when the endpoint was already
struggling. On large accounts with many concurrent agents this
multiplies into sustained load that keeps the query slow, a
self-reinforcing loop.

This PR makes two changes:

1. **Treat an unknown count (`0`) as a large account** and poll at the
slowest cadence instead of the fastest (`getMetaDebounceKey`, extracted
as a pure, testable function). This breaks the failure loop.
2. **Raise the debounce intervals across all tiers** so counts are
polled less frequently under sustained activity:
   - fast: max 1 poll / 2s -> 1 / 5s
   - mid: 1 / 10s -> 1 / 20s
   - slow (large/unknown accounts): 1 / 20s -> 1 / 30s

First-load counts still render immediately (the debounce fires on the
leading edge for the very first call), so the higher intervals only
affect the sustained poll rate, not initial render.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2026-07-16 11:41:28 +05:30
Aakash BakhleandGitHub 6d74ff9477 fix(captain): make assistant switcher scrollable (#15027) 2026-07-16 10:44:22 +05:30
354c2cab6b fix(captain): handle resolved conversation context (#14433)
# Pull Request Template

## Description

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

Uses approaches discussed from:
https://github.com/chatwoot/chatwoot/pull/13883

Activity messages pertaining to resolve are included along with an
instruction for the LLM to choose whether to consider them or not along

## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and with specs


## Checklist:

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-15 23:30:17 +05:30
Aakash BakhleandGitHub 9c68eed676 fix(captain): send custom tool API key headers (#15008)
Captain custom tools configured with API key authentication now send the
configured key in the requested HTTP header. Existing tools begin
working without needing to be recreated or reconfigured, while
credentials remain protected across redirects.

## How to reproduce

1. Create a Captain custom tool using API Key authentication.
2. Configure `X-API-Key` as the header name and save the tool.
3. Invoke the tool and inspect the incoming request.
4. Before this change, the API key header is absent; after this change,
the configured endpoint receives it.

## What changed

The UI persists API key authentication as `name` and `key`, but the
request builder also required an unused `location: header` property. The
request builder now treats API key authentication as header-based,
matching the only mode exposed by the UI.

Custom authentication headers are also registered as sensitive with
`SafeFetch`. They are retained for the configured endpoint and
same-origin redirects, but stripped when a redirect crosses origins to
prevent credential leakage. Factory, request, and redirect specs cover
the real UI payload and both public and private-network fetch paths.
2026-07-15 22:43:30 +05:30
Aakash BakhleandGitHub 6d38b4d39c fix(captain): improve complex migration instructions (#15002)
Improves Captain V1 → V2 migration for complex legacy instructions so
mandatory triggers, workflows, language rules, and escalation behavior
remain active while query-dependent product knowledge is prepared as
pending FAQ candidates.

## What changed

- Added explicit preservation rules for mandatory triggers, verification
steps, escalation conditions, exceptions, and language behavior.
- Added an auditor that checks the draft and fixes any issues before
manual review.
- Kept the existing migration application contract and schema limits
unchanged
- Added focused regression coverage for the complex-prompt classifier
contract.

## How to reproduce

Generate a migration draft for an assistant with dense legacy
instructions containing mandatory handoff triggers, verification rules,
product facts, and multi-step workflows. The resulting draft should keep
actions active, place query-dependent facts in FAQ candidates, and avoid
silently dropping or reversing source requirements.

Focused Captain migration specs and RuboCop checks pass locally.
2026-07-15 14:51:51 +05:30
Aakash BakhleandGitHub fd625981e9 fix(captain): keep custom tools available in Captain V2 (#15015)
Captain V2 assistants can now use every enabled custom tool from their
account through the main assistant. The change keeps existing custom
tool access when an assistant has no migrated scenarios, so switching
from V1 does not remove the capability without warning.

## How to reproduce

1. Create and enable an account custom tool.
2. Use an assistant with no custom instructions and no generated
scenarios.
3. Enable Captain V2 for the account.
4. Before this change, the main assistant receives only FAQ lookup and
handoff. After this change, it also receives the enabled account custom
tool.

## What changed

The main V2 assistant now loads enabled custom tools through its account
association. Scenario agents still load only the tools named in their
scenario instructions. The account custom tool limit keeps the added
tool count bounded.

Focused model coverage verifies enabled tools, disabled tools, account
isolation, FAQ lookup, and handoff. Existing V1 assistant, V2 scenario,
and V2 runner coverage passes. RuboCop passes.
2026-07-15 11:35:57 +05:30
49c442751d fix: require admin for dashboard app mutations (#14831)
## Summary

Restricts account-wide Dashboard App creation, updates, and deletion to
administrators while keeping read access available to authenticated
account users.

## Why

Dashboard Apps are account-level integrations displayed in conversation
views. Agents should be able to use them, but only administrators should
be able to change their configuration.

## What changed

- authorize Dashboard App actions through `DashboardAppPolicy`
- allow index and show access for authenticated account users
- restrict create, update, and destroy actions to administrators
- add request coverage for administrator and agent mutation behavior

## Validation

`bundle exec rspec
spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb`

17 examples, 0 failures.

`bundle exec rubocop
app/controllers/api/v1/accounts/dashboard_apps_controller.rb
app/policies/dashboard_app_policy.rb`

2 files inspected, no offenses detected.



---------

Co-authored-by: Gaurav Singhal <gauravsinghal@Gauravs-Mac-mini.local>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-14 23:02:13 -07:00
13db36609d fix: hide agent bot access tokens from agents (#14830)
## Summary

Keeps Agent Bot list and show access available to agents while
restricting account bot access tokens to administrators.

## Why

Agents need Agent Bot metadata for existing product workflows, but the
bot access token can be replayed against bot-authorized APIs and should
not be exposed to them.

## What changed

- serialize `access_token` only for administrators
- verify agents can read Agent Bot metadata without receiving the token
- verify administrators still receive the token from index and show
responses

## Validation

`bundle exec rspec
spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb`

27 examples, 0 failures.

Related follow-up:
[CW-7595](https://linear.app/chatwoot/issue/CW-7595/standardize-one-time-credential-disclosure-across-chatwoot-apis)



---------

Co-authored-by: Gaurav Singhal <gauravsinghal@Gauravs-Mac-mini.local>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-14 20:00:39 -07:00
Muhsin KelothandGitHub 8b4f3e226e revert: "fix(meta): disable Instagram replies on Cloud during restriction" (#15020)
Reverts chatwoot/chatwoot#15005
2026-07-14 14:59:41 -07:00
Aakash BakhleandGitHub 2ac55c8728 fix(captain): honor mandatory handoff guidelines (#15003)
Ensures Captain follows explicit mandatory-transfer rules from active
Response Guidelines and Guardrails instead of allowing the generic
consent-first fallback to override those rules.

## What changed

- Made explicit transfer requirements take precedence over generic
consent-first handoff defaults only when their condition matches.
- Added explicit Response Guideline and Guardrail transfer rules to the
human-handoff protocol.
- Added focused prompt regression coverage.

## How to reproduce

Configure a Response Guideline or Guardrail that requires immediate
transfer for a specific condition, then send a request matching that
condition. Captain should invoke the human-handoff path without asking
the user to consent again. Unmatched requests continue to use the
existing consent-first fallback.

The assistant prompt renderer, agent prompt context, and focused
regression specs pass locally.
2026-07-14 19:38:43 +05:30
9328f8739c fix: clear whatsapp webhook override when manual cloud inbox is deleted (#15010)
Deleting a manually-configured WhatsApp Cloud inbox left its
phone-number-level webhook override still pointing at Chatwoot on Meta's
side. The number kept routing inbound events to us after the inbox was
gone, which blocked the customer's own app — subscribed separately on
the same WABA — from receiving messages, since the phone-level override
takes priority over the app-level subscription. Deleting the inbox now
releases the override, as it already did for embedded-signup inboxes.

## What changed

The setup and teardown paths gated on opposite halves of the same
condition. `Channel::Whatsapp#should_auto_setup_webhooks?` sets the
override for `whatsapp_cloud` inboxes where `source !=
'embedded_signup'` (i.e. manual ones), while
`Whatsapp::WebhookTeardownService#should_teardown_webhook?` only cleared
it when `source == 'embedded_signup'`. The two sets are disjoint, so
manual inboxes were exactly the ones that set an override on create and
never cleared it on destroy. Embedded-signup inboxes were unaffected
because `EmbeddedSignupService` calls `setup_webhooks` explicitly.

Dropping the `source` check from the teardown guard is the whole fix.
Manual `whatsapp_cloud` channels can't persist without `api_key`,
`phone_number_id` and `business_account_id` (`validate_provider_config`
verifies all three against Meta), so the remaining presence guards and
both API calls have everything they need. The WABA-level `DELETE
/subscribed_apps` now also fires for manual inboxes when the last one on
a WABA is removed, which is symmetric with manual setup subscribing the
app in the first place; the token only unsubscribes the app it belongs
to, so a customer's separate app subscription is untouched.

This fixes the leak going forward. Numbers already stranded still need
the override cleared with the customer's own token, since we no longer
hold their `api_key` once the inbox is deleted.

## How to reproduce

1. Create a WhatsApp Cloud inbox using manual API keys (not embedded
signup).
2. Confirm the override is set: `GET
/v22.0/{phone_number_id}?fields=webhook_configuration` shows
`phone_number` pointing at your Chatwoot install.
3. Delete the inbox.
4. Before this change, the override still points at Chatwoot. After it,
`webhook_configuration` no longer carries the phone-level override and
events fall back to the WABA/app-level subscription.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-14 15:05:27 +05:30
3e03f8da1e chore(whatsapp): log warning when Cloud API template sync fails (#15004)
WhatsApp Cloud API template sync currently fails silently — if the Graph
API call errors (expired token, rate limit, permission issue), the
channel simply keeps its stale templates with no trace in the logs. This
adds a warning log when the template fetch fails, so failed syncs are
visible and debuggable.

## What changed

- `Whatsapp::Providers::WhatsappCloudService#fetch_whatsapp_templates`
now logs a warning with the account id, inbox id, HTTP status code, and
Meta's error message when the response is not successful.
- The inbox id uses safe navigation since sync also runs from the
channel's `after_create` callback, before the inbox record exists.
- The request URL is intentionally not logged, as it contains the access
token as a query param.

## How to reproduce

1. Set up a WhatsApp Cloud inbox with an invalid/expired `api_key`.
2. Trigger a template sync (Inbox settings → sync templates, or wait for
the scheduler).
3. Previously nothing was logged; now a `[WHATSAPP] Template sync failed
for account ... inbox ...` warning appears in the Rails logs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-14 13:35:10 +04:00
280756b483 fix(meta): disable Instagram replies on Cloud during restriction (#15005)
Agents can no longer send replies in Instagram conversations on Chatwoot
Cloud while the temporary Meta platform restriction is active. The reply
box locks into Private Note mode — the same behavior as an expired
24-hour reply window — so teams can still collaborate internally, with
the existing amber restriction banner above the conversation explaining
why. Self-hosted installations are unaffected.

Follow-up to #14974.


## How to test

1. On a Chatwoot Cloud environment (`isOnChatwootCloud` true), open any
Instagram conversation.
2. The composer should be locked to Private Note mode: the Reply/Private
Note toggle is disabled, and sending creates a private note — even for
conversations within the 24-hour reply window.
3. Switching between conversations should keep the composer in Private
Note mode for Instagram conversations.
4. On a self-hosted environment, Instagram conversations should behave
as before (reply allowed within the messaging window).

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-14 13:30:19 +04:00
Aakash BakhleandGitHub 102f19fe41 feat(captain): add FAQ suggestion data model (1/3) (#14977)
Resolved conversations need a separate suggestion layer so repeated FAQ
signals can be grouped without creating untrusted knowledge entries.
This PR adds the persistence foundation only; it introduces no
user-facing behavior by itself.

## Closes

-
[CW-7495](https://linear.app/chatwoot/issue/CW-7495/backend-llm-changes-to-make-conversation-faqs-as-signalssuggestions)
(stacked PR 1/3; the issue is complete after the full stack lands)

## What changed

- Added `captain_faq_suggestions` with question, answer, embedding,
source count, and review status.
- Added `captain_faq_observations` to retain conversation-level signals.
- Added Captain assistant, account, and conversation associations.
- Added vector and lookup indexes for semantic grouping.

## How to test

This layer has no standalone UI behavior. Apply the migration and
confirm Captain assistants can persist open FAQ suggestions with
attached conversation observations.
2026-07-14 14:31:08 +05:30
1b6a80d84d fix(captain): improve conversation completion evaluation (#14967)
# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes
https://linear.app/chatwoot/issue/AI-136/check-conversation-status-while-auto-resolving

- After 60mins of inactivity, we run a job that decides if pending
conversations are resolvable or need handoff
- the prompt was a bit conservative and didn't have conversation state
context

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally ran a sample eval 

## Checklist:

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-14 11:45:40 +05:30
Shivam MishraandGitHub 9c444315a6 feat: add api_and_webhooks feature flag reconciled from billing plan (#14972)
This introduces a new `api_and_webhooks` account feature flag that will
control access to the token-authenticated API and account webhooks. The
flag is part of the Startup plan features, so paid plans — including
trials of paid plans — get it through the billing reconcile, while
accounts on the default (Hacker) plan don't, with
`manually_managed_features` available as a per-account override. The
flag defaults to enabled, and nothing enforces it yet, so this PR is
behavior-neutral — enforcement lands in a follow-up.

## What changed

- Added `api_and_webhooks` to `features.yml` (first flag on the
`feature_flags_ext_1` column, default enabled).
- Added the flag to `STARTUP_PLAN_FEATURES` in
`Enterprise::Billing::ReconcilePlanFeaturesService`, so all paid tiers
get it and the default plan loses it on reconcile.
- Added the flag to the manually manageable features list so it can be
granted per account via Super Admin.

```rb
# Enables the api_and_webhooks feature for all existing accounts and marks it
# as manually managed so cloud billing reconciles never strip it.
#
# NOT committed to source control — run manually on production.
#
# Usage:
#   bundle exec rails runner enable_api_and_webhooks.rb
#   ACCOUNT_ID=123 bundle exec rails runner enable_api_and_webhooks.rb
#
# Idempotent: accounts already grandfathered are skipped; safe to re-run.

probe = Internal::Accounts::InternalAttributesService.new(Account.new)
abort 'api_and_webhooks is not in valid_feature_list — deploy the feature flag PR first.' unless probe.valid_feature_list.include?('api_and_webhooks')

account_id = ENV.fetch('ACCOUNT_ID', nil)
accounts = account_id.present? ? Account.where(id: account_id) : Account.all

abort "Account with ID #{account_id} not found" if account_id.present? && accounts.empty?

total = accounts.count
puts "Grandfathering api_and_webhooks for #{total} account(s)..."
puts "Started at: #{Time.current}"

updated = 0
skipped = 0
errored = 0

accounts.find_each(batch_size: 500) do |account|
  service = Internal::Accounts::InternalAttributesService.new(account)
  features = service.manually_managed_features

  if features.include?('api_and_webhooks') && account.feature_enabled?('api_and_webhooks')
    skipped += 1
  else
    service.manually_managed_features = features + ['api_and_webhooks'] unless features.include?('api_and_webhooks')
    account.enable_features!('api_and_webhooks')
    updated += 1
  end

  processed = updated + skipped + errored
  puts "Processed #{processed}/#{total}..." if (processed % 1000).zero?
rescue StandardError => e
  errored += 1
  puts "Account #{account.id}: FAILED - #{e.message}"
end

puts "Done! Updated: #{updated}, Skipped: #{skipped}, Errored: #{errored}, Total: #{total}"
```
2026-07-13 18:20:36 +05:30
df7f137657 feat: add captain sessions model [CW-7485] (#14970)
This adds a `captain_sessions` table to log every Captain run, starting
with Assistant Responses and Copilot Responses. Each session records the
assistant, model, credits consumed, the FAQs/documents/scenario that
contributed to the response, and the full run context — giving customers
visibility into how a response was generated and giving us durable stats
on credit, FAQ, and document usage (which today only exist as ephemeral
trace metadata and an aggregate account counter).

## What changed

- New `Captain::Session` model with a `session_type` enum (`assistant`,
`copilot`). The subject (`Conversation` / `CopilotThread`) and result
(`Message` / `CopilotMessage`) classes are inferred from the session
type, so the table stores plain `subject_id` / `result_id` ids.
`result_id` is nullable so failed runs that still consumed credits can
be logged.
- Composite indexes on `[session_type, subject_id]`, `[session_type,
result_id]`, and `[account_id, session_type, created_at]` for lookup and
usage-stats queries.
- Factory and model specs.

This PR is schema + model only; the writer/instrumentation that records
sessions from the assistant and copilot flows will follow.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-13 18:18:20 +05:30
Vishnu NarayananandGitHub 056b5eb89d fix: avoid full scan in IMAP email dedup on large inboxes (#14981)
## Description

`Imap::BaseFetchEmailService#email_already_present?` used
`find_by(source_id:)`, which inherits `Message`'s `default_scope {
order(created_at: :asc) }`, adding an `ORDER BY created_at ASC LIMIT 1`
to what is only a presence check.

On inboxes with a large message history, that `ORDER BY` lets Postgres
satisfy the sort by walking `index_messages_on_created_at` instead of
the selective `index_messages_on_source_id`. For a not-yet-seen
`source_id` (every new email) it can scan the whole table before
returning, taking seconds per message. The dedup loop runs with no IMAP
activity in between, so the idle socket is dropped by the mail server
and the fetch job aborts with `closed stream`. The inbox then stops
ingesting mail entirely, while smaller inboxes on the same server keep
working.

`exists?` issues `SELECT 1 ... LIMIT 1` with no `ORDER BY`, so the
planner uses `index_messages_on_source_id` regardless of table size. No
schema change is required. The fix lives in the shared base class, so it
covers both the IMAP and Microsoft fetch paths.

Fixes #14682
2026-07-13 18:15:25 +05:30
08260f3be7 feat: show crawled document details and FAQ counts in Captain (#14863)
- Add a document details view that surfaces crawled content, source
metadata, and generated FAQ counts.
- Rename the document card action to open details and show the FAQ count
inline in the list.
- Return `responses_count` from the documents API efficiently and expose
document content in the show payload.
- Update related Captain copy to reflect the new details-oriented flow. 


**Preview**
<img width="1640" height="1596" alt="CleanShot 2026-06-26 at 09 25
15@2x"
src="https://github.com/user-attachments/assets/0c408fae-7d37-422a-8869-ece466292cb1"
/>

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-07-13 17:47:00 +05:30
11c65f3b9a feat: Intercom import workflow (#14922)
## Description

Adds an admin-only Intercom import workflow under Settings > Data.
Admins can connect an Intercom access token, start named historical
contact/conversation imports, monitor active and previous import runs,
review paginated skip/error logs, download skip logs, and route imported
conversations into source-bucket API inboxes that can be renamed later.

The import path stores durable source mappings, batches Intercom
contact/conversation pages through Sidekiq, records already-imported
records as skipped, and writes historical messages without normal
outbound delivery callbacks. The PR also includes the Intercom import
PRD/TDD document for review context.

Closes
[CW-7519](https://linear.app/chatwoot/issue/CW-7519/explore-intercom-import)

## Type of change

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

## How Has This Been Tested?

Tested importing using actual data through integration.

Screenshots:

<img width="1800" height="948" alt="Screenshot 2026-07-02 at 10 48
48 PM"
src="https://github.com/user-attachments/assets/e74d9ed6-0bca-47de-b6ef-e589afcddfde"
/>
<img width="1800" height="1008" alt="Screenshot 2026-07-02 at 10 49
03 PM"
src="https://github.com/user-attachments/assets/1bd12fdb-0a47-4287-ac1d-ea308e70a9cd"
/>
<img width="1800" height="1005" alt="Screenshot 2026-07-02 at 10 49
21 PM"
src="https://github.com/user-attachments/assets/3d8145f5-1794-4cc3-b3fa-de5cd80e6ca3"
/>
<img width="1800" height="1002" alt="Screenshot 2026-07-02 at 10 49
38 PM"
src="https://github.com/user-attachments/assets/6f818efd-4193-43c2-84eb-66970dca4490"
/>


Passed locally:

```sh
eval "$(rbenv init -)" && bundle exec rspec spec/models/data_import_spec.rb spec/jobs/data_import_job_spec.rb spec/requests/api/v1/accounts/data_imports_spec.rb spec/requests/api/v1/accounts/integrations/intercom_spec.rb spec/jobs/data_imports/intercom/import_jobs_spec.rb spec/services/data_imports/intercom/importer_spec.rb spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb spec/services/data_imports/intercom/source_bucket_spec.rb
```

```sh
eval "$(rbenv init -)" && bundle exec rubocop app/controllers/api/v1/accounts/data_imports_controller.rb app/controllers/api/v1/accounts/integrations/intercom_controller.rb app/jobs/data_imports/intercom app/models/data_import.rb app/models/data_import_error.rb app/models/data_import_item.rb app/models/data_import_mapping.rb app/models/integrations/hook.rb app/policies/data_import_policy.rb app/policies/hook_policy.rb app/services/data_imports/intercom db/migrate/20260702000000_expand_data_imports_for_intercom_imports.rb db/migrate/20260702000001_create_data_import_items.rb db/migrate/20260702000002_create_data_import_mappings.rb db/migrate/20260702000003_create_data_import_errors.rb spec/jobs/data_imports/intercom spec/requests/api/v1/accounts/data_imports_spec.rb spec/requests/api/v1/accounts/integrations/intercom_spec.rb spec/services/data_imports/intercom
```

```sh
pnpm exec eslint app/javascript/dashboard/api/dataImports.js app/javascript/dashboard/api/integrations.js app/javascript/dashboard/routes/dashboard/settings/data/Index.vue app/javascript/dashboard/routes/dashboard/settings/data/Show.vue app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js app/javascript/dashboard/routes/dashboard/settings/integrations/Intercom.vue app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js app/javascript/dashboard/routes/dashboard/settings/settings.routes.js app/javascript/dashboard/components-next/sidebar/Sidebar.vue app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue
```

```sh
git diff --check
```

Note: the RSpec boot logs the existing local `chatwoot_dev` purge
warning because other database sessions are open, then continues and
completes with 52 examples, 0 failures.

## Checklist:

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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-13 15:25:07 +05:30
8fc5c7a5c8 feat: add captain general guidelines migration helpers (#14909)
This PR adds internal tooling and planning docs for migrating existing
Captain assistant instructions into the new General Guidelines
structure.


**Summary**

This PR adds a controlled migration path for moving existing Captain V1
assistant instructions into the structured Captain architecture.

It introduces a classifier that reads the current `config.instructions`
and produces reviewed migration drafts with separate sections for:

- assistant description / business context
- response guidelines
- guardrails
- scenario candidates
- conversation messages
- FAQ/document candidates
- needs-review items

The migration is intentionally staged. It only targets V1-style
assistants that still have custom instructions, are connected to
inboxes, and do not already have structured response guidelines,
guardrails, or scenario records.

When applied, the task writes the extracted business context to the
assistant description, response guidelines to `response_guidelines`,
guardrails to `guardrails`, and stores scenario candidates / FAQ
candidates / review notes under `config["assistant_migration"]`.
Scenario candidates are also flattened into response guidelines for now
so customer behavior is preserved before we create real
`Captain::Scenario` records in a later rollout.

The applier stores the original assistant values under migration
metadata so conversation message config can be restored if needed. It
does not create scenario records yet.

**How to generate drafts**

For specific assistant IDs:

```bash
bundle exec rake captain:assistant_migration:generate \
  IDS=546,636,819 \
  LIMIT=0 \
  OUTPUT=tmp/captain_migration_drafts.jsonl
```

For the first 50 eligible assistants:

```bash
bundle exec rake captain:assistant_migration:generate \
  OUTPUT=tmp/captain_migration_drafts.jsonl
```

For all eligible assistants:

```bash
bundle exec rake captain:assistant_migration:generate \
  LIMIT=0 \
  OUTPUT=tmp/captain_migration_drafts.jsonl
```

**How to apply drafts**

Dry run first:

```bash
bundle exec rake captain:assistant_migration:apply \
  INPUT=tmp/captain_migration_drafts.jsonl \
  DRY_RUN=true
```

Apply changes:

```bash
bundle exec rake captain:assistant_migration:apply \
  INPUT=tmp/captain_migration_drafts.jsonl \
  DRY_RUN=false
```

**How to restore conversation messages**

If extracted `welcome_message`, `handoff_message`, or
`resolution_message` need to be reverted to their pre-migration values:

```bash
bundle exec rake captain:assistant_migration:restore_messages \
  IDS=546,636,819 \
  DRY_RUN=true
```

```bash
bundle exec rake captain:assistant_migration:restore_messages \
  IDS=546,636,819 \
  DRY_RUN=false
```

**Notes**

- `LIMIT=0` means no limit.
- `generate` overwrites the output file.
- The apply task skips assistants that are no longer V1 migration
candidates.
- This PR does not create `Captain::Scenario` records; scenario
candidates are staged in assistant config for a future migration.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-07-13 14:56:09 +05:30
Shivam MishraandGitHub 7caa4e0bbc fix: pin pnpm version in CircleCI node orb install step (#14999)
CI runs were failing because the CircleCI Node orb's pnpm auto-install
step matches the wrong "version" field in pnpm's package.json, producing
an invalid install command since pnpm 11.12.0.

Ref: https://github.com/CircleCI-Public/node-orb/issues/262

### What changed
- Pinned `pnpm-version: 10.2.0` (matching `packageManager` in
`package.json`) on all three `node/install-pnpm` steps in
`.circleci/config.yml`, bypassing the orb's broken version
auto-detection.
2026-07-13 14:48:47 +05:30
Tanmay Deep SharmaandGitHub 9c10fe4eb1 fix: default assignment age exclusion (#14998)
## Description
Auto-assignment was skipping the 7-day staleness check entirely for
inboxes that don't have an assignment policy attached. Those inboxes
would pull unassigned conversations of any age off the backlog and hand
them to agents — including conversations untouched for months — while
the activity log still credited "Default Policy" for the assignment.
This makes the default behaviour match what that label implies: with no
policy configured, conversations with no activity in the last 7 days are
now excluded, the same window a freshly created policy uses.

## Type of change

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


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-13 14:01:47 +05:30
Shivam MishraandGitHub 6b3800e25b fix: pre-chat form contact custom attributes lost for existing contacts (#14956)
Contact custom attributes filled in the pre-chat form (e.g. a CPF field)
were silently lost when the visitor's email or phone matched an existing
contact. The widget sent the attributes in a separate request that raced
the conversation create: creating the conversation merges the widget
contact into the existing contact, so the attribute update landed on the
destroyed contact and vanished without an error. New visitors were
unaffected, which made the bug hard to spot.

The fix sends contact custom attributes inside the same request that
identifies the contact. The widget conversation create endpoint now
accepts `contact.custom_attributes` and applies them through
`ContactIdentifyAction` in the same transaction as the merge, so the
submitted values always land on the surviving contact. The campaign path
folds the attributes into the existing contact update call the same way.

<details>
<summary>Reproduction script (rails runner, concurrent
threads)</summary>

```ruby
# Concurrent reproduction of the pre-chat form race that loses contact
# custom attributes when the visitor's email matches an existing contact.
#
# The old widget fired two unawaited requests on pre-chat submit. Each
# iteration replays them as real concurrent threads:
#   - create thread = POST /widget/conversations: resolves the widget contact,
#     then runs ContactIdentifyAction (which merges the widget contact into the
#     existing contact) inside a transaction held open while the conversation
#     and message are created.
#   - patch thread = PATCH /widget/contact: resolves the widget contact via its
#     contact inbox and applies the custom attributes through
#     ContactIdentifyAction, exactly like Widget::ContactsController#update.
#
# Run with: bundle exec rails runner confirm_prechat_race.rb
# Creates throwaway contacts on Account.first and deletes them afterwards.

ITERATIONS = 20
CPF = '123.456.789-09'.freeze

account = Account.first!
inbox = account.inboxes.first!
losses = 0

ITERATIONS.times do |i|
  existing = account.contacts.create!(name: 'Existing Contact', email: "race-existing-#{SecureRandom.hex(6)}@example.com")
  temp = account.contacts.create!(name: 'Widget Visitor')
  contact_inbox = ContactInbox.create!(contact: temp, inbox: inbox, source_id: SecureRandom.uuid)

  begin
    create_request = Thread.new do
      ActiveRecord::Base.connection_pool.with_connection do
        widget_contact = ContactInbox.find(contact_inbox.id).contact # set_contact before_action
        ActiveRecord::Base.transaction do
          ContactIdentifyAction.new(
            contact: widget_contact,
            params: { email: existing.email, phone_number: nil, name: 'Widget Visitor' },
            retain_original_contact_name: true,
            discard_invalid_attrs: true
          ).perform
          sleep(0.02) # conversation + message creation keeps the transaction open
        end
      end
    end

    patch_request = Thread.new do
      ActiveRecord::Base.connection_pool.with_connection do
        sleep(rand * 0.02) # network jitter between the two requests
        widget_contact = ContactInbox.find(contact_inbox.id).contact # set_contact before_action
        ContactIdentifyAction.new(
          contact: widget_contact,
          params: { custom_attributes: { cpf: CPF } },
          discard_invalid_attrs: true
        ).perform
      end
    end

    create_request.join
    patch_request.join

    survivor = existing.reload
    if survivor.custom_attributes['cpf'] == CPF
      puts "iteration #{i + 1}: CPF survived"
    else
      losses += 1
      puts "iteration #{i + 1}: CPF LOST (survivor custom_attributes: #{survivor.custom_attributes.inspect})"
    end
  ensure
    account.contacts.where(id: [existing.id, temp.id]).find_each(&:destroy!)
  end
end

puts
puts "#{losses}/#{ITERATIONS} iterations lost the CPF -- race #{losses.positive? ? 'confirmed' : 'not reproduced in this run'}"
```

Result: **20/20 iterations lose the CPF** (consistent across repeated
runs). The conversation create holds its transaction open across the
contact merge, so the fast attribute update either resolves the
soon-to-be-destroyed widget contact or blocks on its row lock and then
updates 0 rows — silently, with no error. This is why the customer sees
the loss every time, not intermittently.

Note: the script replays the old two-request flow at the service layer,
so it reproduces the loss even with this fix applied — the fix works by
removing the second request from the widget, not by changing the raced
code paths. The new request spec (`saves contact custom attributes on
the surviving contact when merged into an existing contact`) covers the
fixed contract.

</details>

## Closes

- Reported via support conversation:
https://app.chatwoot.com/app/accounts/1/conversations/84465

## How to reproduce

1. Create a website inbox with a pre-chat form that has a contact custom
attribute field (e.g. CPF).
2. Create a contact with a known email address.
3. As a visitor, open the widget and fill the pre-chat form using that
same email plus a value for the custom attribute.
4. Before: the attribute never appears on the contact profile. After: it
is saved on the existing contact, overwriting any stale value.

## What changed

- `POST /api/v1/widget/conversations` now permits
`contact.custom_attributes` and passes it to `ContactIdentifyAction`,
which deep-merges it atomically with the contact merge.
- The widget pre-chat form sends contact custom attributes inside the
conversation create payload instead of a separate `setCustomAttributes`
call; the campaign path includes them in the `contacts/update` payload.
2026-07-13 13:51:58 +05:30
b560720e08 feat: allow switching embedded signup whatsapp embedded inboxes to manual setup (#14975)
Since embedded signup has been disabled on production, WhatsApp inboxes
that were created through it have no way to be managed going forward.
This PR lets admins transfer an embedded signup inbox to a manual Cloud
API setup: the inbox settings Configuration tab now shows the webhook
verification token and a "Switch to Manual Setup" form (pre-populated
with the Phone Number ID, Business Account ID, and API key stored during
the embedded signup journey) instead of the old Reconfigure button.

Saving the form updates the channel's `provider_config` without the
`source: embedded_signup` marker, so the inbox becomes a regular
manually-configured WhatsApp Cloud inbox. The backend re-validates the
submitted credentials against Meta before accepting the change.

## How to test

1. Open the settings page of a WhatsApp inbox created via embedded
signup → Configuration tab.
2. The Reconfigure button is gone; you see the webhook verification
token and a Switch to Manual Setup form pre-filled with the stored
credentials.
3. Configure the webhook in your own Meta app using the verification
token, enter a permanent access token from that app, and click "Switch
to Manual Setup".
4. On success the page switches to the standard manual configuration
view (verify token, API key update), and messaging continues to work
with the new credentials. Invalid credentials are rejected with an
error.

## What changed

- `ConfigurationPage.vue`: replaced the embedded-signup Reconfigure
section (and the hidden reauthorize component) with the manual transfer
form.
- New `WHATSAPP_MANUAL_TRANSFER_*` translation keys.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-13 13:48:38 +05:30
Aakash BakhleandGitHub cc87429903 feat(captain): expand assistant description limit (#14985)
# Pull Request Template

## Description

Increases description for Captain.

Why?
We are planning to include business context in description and 255 char
limit on the column and 200 char limit on the UI are very limiting to
get proper context.

## Type of change

Improvement to accommodate business context

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-13 12:59:36 +05:30
98154bbeab fix(meta): show restriction alerts for inbox setup (#14974)
Instagram inbox creation and WhatsApp embedded signup on Chatwoot Cloud
now reflect the temporary Meta restriction. Instagram is hidden from
onboarding on Cloud, while the regular Instagram inbox creation page
shows a disabled action with a status-linked amber warning. WhatsApp
embedded signup on Cloud stays visible with its connect action disabled.
WhatsApp Call setup always uses the manual WhatsApp form.

Existing Instagram conversations and Instagram inbox settings on Cloud
also show amber warning banners with the public incident link.
Self-hosted installations keep their existing Instagram, WhatsApp, and
WhatsApp Call setup behavior because the temporary restriction is based
only on the Chatwoot Cloud environment check.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-10 16:22:49 +04:00
Sivin VargheseandGitHub 03a1b1dbc1 chore: insert resolved variable value in reply editor (#14921)
# Pull Request Template

## Description

This PR makes reply editor variables insert their resolved value (for
example, the contact's name) instead of the raw `{{contact.name}}`
placeholder, matching canned response behavior. This works both when
picking a variable from the `{{` menu and when an agent manually types
out `{{contact.name}}` — it resolves the moment the closing `}}` is
typed.

If a variable has no value, the `{{placeholder}}` is kept so the backend
can still resolve it when the message is sent. Private notes are left
untouched.

For safety, a resolved value that itself contains Liquid syntax `({{ }}`
or `{% %})` also keeps its placeholder, so customer-controlled fields
can never inject Liquid into the outgoing message.

Fixes
https://linear.app/chatwoot/issue/CW-7528/reply-editor-inserts-variable-placeholder-instead-of-the-value

## Type of change

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

## How Has This Been Tested?

1. Open a conversation and add a reply.
2. Type `{{` and pick a variable that has a value (e.g. Contact name) →
it inserts the actual value.
3. Manually type `{{contact.name}}` and close the braces → it
auto-resolves to the value.
4. Insert/type a variable with no value → the `{{placeholder}}` stays;
confirm it resolves correctly on send.
5. Repeat in a private note → placeholders are left as-is.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-10 17:24:34 +05:30
Sony MathewandGitHub 848e94bcf2 fix: throttle filtered unread count rebuilds (#14980)
Reduces database pressure from filtered unread-count cache rebuilds
during high-traffic account rollouts by serving stale snapshots longer
and limiting inline saved-filter rebuild fanout.

## Closes

None

## What changed

- Increase filtered unread-count refresh throttling from 30 seconds to 5
minutes.
- Increase the stale snapshot window from 30 minutes to 1 hour.
- Reduce inline saved-filter count rebuilds per request from 10 to 3.
- Update unread-count specs to assert refresh and stale behavior through
the shared constants.

## How to test

- Enable `conversation_unread_counts` and `unread_count_for_filters` for
an account with conversation custom filters.
- Open the dashboard and verify unread-count badges still return values.
- Mutate conversations and verify stale filtered counts are served while
rebuilds are throttled, instead of repeatedly rebuilding every 30
seconds.
2026-07-10 17:23:00 +05:30
Tanmay Deep SharmaandGitHub 56dc580f50 feat: support manual setup for WhatsApp calls inbox creation (#14976)
Re-enables the "WhatsApp Calls" inbox creation option, which disappeared
when embedded signup was disabled on production. The channel card now
only requires the `channel_voice` account feature, and the creation flow
offers the manual WhatsApp Cloud API setup form. Once the channel is
created with manual credentials, calling is enabled automatically — the
same post-setup step the embedded signup flow used to perform.

When an installation has embedded signup configured, the flow still uses
it; the manual form is the fallback (and the effective path on Chatwoot
Cloud today).

## How to test

1. Enable the `channel_voice` feature on the account.
2. Go to Add Inbox → the "WhatsApp Calls" card is visible again → select
it.
3. Fill in the manual Cloud API credentials (inbox name, phone number,
phone number ID, business account ID, API key) and submit.
4. The inbox is created and calling is enabled:
`provider_config.calling_enabled` is true and the Calls tab toggle is
on. If the number isn't enrolled in the Business Calling API, an alert
explains the enable failure but the messaging inbox is still created.

## What changed

- `ChannelItem.vue`: the `whatsapp_call` card is gated only on
`channel_voice` (previously also required the embedded signup app ID).
- `CloudWhatsapp.vue`: new `enableCallingOnComplete` prop that calls the
enable-calling API after channel creation.
- `WhatsappCall.vue`: renders embedded signup when available, otherwise
the manual setup form — both with calling enabled on completion.
2026-07-10 15:24:14 +05:30
35fcd56ba9 feat: add support action to suspended account page (#14969)
Updates the suspended account page with the revised policy copy and adds
a visible Contact support action that opens the embedded Chatwoot
support widget.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-09 16:45:47 +04:00
Aakash BakhleandGitHub d57354c8b5 feat: tighten conversation FAQ generation prompt (#14957)
Tightens the resolved-conversation FAQ generator so it only proposes
durable, reusable FAQ candidates supported by human support-agent
messages. The implementation now sends a conversation-FAQ-specific
transcript to the LLM: customer messages plus real human support-agent
messages only, excluding bot, private, activity, and template messages.

## Closes

-
https://linear.app/chatwoot/issue/CW-7494/tighten-conversation-faq-generation-prompt

## What changed

- Added a human-only transcript builder in `ConversationFaqService`
instead of using the generic `conversation.to_llm_text` output.
- Excluded bot/agent-bot messages before the LLM call, which removes the
main bot-line leakage class deterministically.
- Preserved native-channel human replies where outgoing messages are
stored as `external_echo` without a `User` sender.
- Kept a prompt decision gate requiring each FAQ to be backed by a
complete public human-agent answer.
- Added generic no-FAQ classes for spam, wrong-service conversations,
private account/payment/order/certificate/troubleshooting cases, support
workflow mechanics, and direct-link/file/quote outputs.
- Added a separate `conversation_faq_generation` model route defaulting
to `gpt-5.2`, while keeping `document_faq_generation` on its existing
`gpt-4.1-mini` default. Conversation FAQ generation passes that feature
default ahead of the legacy global `CAPTAIN_OPEN_AI_MODEL` setting
unless an account-level override is configured.
- Kept the prompt domain-neutral so it can still generate reusable
product, service, policy, setup, and process FAQs outside SaaS contexts.

## Sampling notes

- Production Langfuse traces showed `llm.captain.conversation_faq` calls
using `gpt-4.1` in the sampled account set.
- Locally, `Llm::FeatureRouter.resolve(feature:
'conversation_faq_generation')` now resolves to `gpt-5.2`.
- Reviewed recent production `llm.captain.conversation_faq` traces
across 13+ accounts in compact form.
- Replayed 20 full traces across 10 accounts/domains, including
education, hosting, retail/auto, APIs, logistics, tax/fiscal workflows,
and Chatwoot account 1.
- Explicit `gpt-5.2` replay with human-only conversation history
returned no FAQ for 15/20 traces.
- A comparison replay with `gpt-4.1-mini` returned no FAQ for only 7/20
traces, bringing back several private/order/payment/support-workflow
cases.
- Remaining non-empty `gpt-5.2` outputs are now mostly
borderline/possibly useful human-agent-derived FAQs rather than obvious
bot-sourced answers.

## How to test

- Resolve conversations where the answer came only from the bot; no
pending FAQ should be generated.
- Resolve spam, unrelated, wrong-service, or private
payment/order/account conversations; no pending FAQ should be generated.
- Resolve conversations that require account/order/payment/login/private
verification or a human handoff; no pending FAQ should be generated.
- Resolve a conversation where a human agent gives a stable, reusable
help-center answer; the generated pending FAQ should be general and
self-contained.
2026-07-09 17:47:47 +05:30
8d2ef4ec5e feat: assistant overview drilldown reports [CW-7408] (#14920)
<img width="2616" height="1716" alt="CleanShot 2026-07-09 at 14 22
42@2x"
src="https://github.com/user-attachments/assets/504e58df-0243-4cb9-a811-62504fdcb0ac"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-09 16:25:22 +05:30
bddb561546 chore: improve account suspended message (#14968)
Updates the suspended account screen copy to use clearer policy and
safety-oriented language, while giving users a direct support path if
they believe the suspension is a mistake.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-09 13:01:08 +04:00
Vishnu NarayananandGitHub 20227403ce fix: return public contact inbox payload after update (#14946)
# Pull Request Template

## Description
This keeps the public inbox contact update response on the public
contact inbox serializer shape after applying the contact identify
update.

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec
spec/controllers/public/api/v1/inbox/contacts_controller_spec.rb`

## Checklist:

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

Fixes
[https://linear.app/chatwoot/issue/CW-6937](https://linear.app/chatwoot/issue/CW-6937)
Fixes
[https://linear.app/chatwoot/issue/CW-7464](https://linear.app/chatwoot/issue/CW-7464)
Fixes
[https://linear.app/chatwoot/issue/CW-7457](https://linear.app/chatwoot/issue/CW-7457)
2026-07-09 13:50:01 +05:30
Vishnu NarayananandGitHub 0a13dbedfb fix: force account picker on microsoft email inbox OAuth (#14794)
## Description

The Microsoft authorize URL had no `prompt` parameter. With Microsoft's
default behavior, a browser carrying an active Microsoft session is
silently signed in to that account. If the same account is already
attached to an inbox in the same Chatwoot account, the callback treats
the OAuth response as a re-auth and routes the user to the existing
inbox settings page instead of letting them create the new inbox they
intended.

Adding `prompt=select_account` interrupts SSO and always presents the
Microsoft account picker, so the user explicitly chooses the mailbox
they want to connect.

This is distinct from `prompt=consent`, which was removed in
[#13962](https://github.com/chatwoot/chatwoot/pull/13962) to fix the
admin consent loop. `select_account` only interrupts SSO and does not
retrigger the consent dialog.

Closes INF-76

## Type of change

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

## How Has This Been Tested?

`bundle exec rspec
spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb`
(3 examples, 0 failures). The regression test introduced in
[#13962](https://github.com/chatwoot/chatwoot/pull/13962) is updated to
assert `prompt=select_account` instead of asserting absence of the
parameter.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-09 13:49:33 +05:30
Muhsin KelothandGitHub 8d554f2b74 chore(inbox): disable Instagram inbox creation (#14964)
Reverts chatwoot/chatwoot#14955
2026-07-09 10:02:02 +04:00
Sony MathewandGitHub 4854fb7b4b chore: upgrade websocket-driver (#14961)
# Pull Request Template

## Description

Updates `websocket-driver` from `0.7.7` to `0.8.2` to resolve the
bundle-audit findings for CVE-2026-54463, CVE-2026-54464,
CVE-2026-54465, and GHSA-2x63-gw47-w4mm.

This is a lockfile-only transitive dependency update through Rails
Action Cable. No application code changes are included.

Fixes # (issue)
N/A

## Type of change

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

## How Has This Been Tested?

- `bundle exec bundle audit update && bundle exec bundle audit check -v`
- `bundle check && bundle exec ruby -e 'require "action_cable"; require
"action_cable/connection/client_socket"; require "websocket/driver";
puts "actioncable=#{ActionCable::VERSION::STRING}"; puts
"websocket-driver=#{Gem.loaded_specs["websocket-driver"].version}"; puts
"driver_rack=#{WebSocket::Driver.respond_to?(:rack)}"'`
- `POSTGRES_DATABASE=chatwoot_test_3d6a RAILS_ENV=test bundle exec rails
db:prepare && POSTGRES_DATABASE=chatwoot_test_3d6a bundle exec rspec
spec/listeners/action_cable_listener_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas (not applicable; lockfile-only change)
- [ ] I have made corresponding changes to the documentation (not
applicable; dependency security update)
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works (not applicable; dependency security update)
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules (not applicable)
2026-07-08 15:52:40 -07:00
Sony MathewandGitHub 66cfb26c77 feat: Add unread count filters feature flag (1/6) (#14885)
## Description

Adds the account-level `unread_count_for_filters` feature flag as the
dark-launch gate for filtered sidebar unread counts. This reuses the
deprecated `quoted_email_reply` flag slot, resets the reused bit for
existing accounts, and removes stale defaults so new accounts do not
reference the old flag.

This also adds the feature where we are now calculating the unread counts for built in filters like mentions, participating and unattended along with unread count for saved filters/folders.

Closes
[CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
2026-07-08 23:50:40 +05:30
Shivam MishraandGitHub b9536fb8ed fix: use reserved example domain in SAML placeholders (#14958) 2026-07-08 21:55:38 +05:30
Sony MathewandGitHub 873d16f54c feat: Extend account feature flag storage (#14947)
# Pull Request Template

## Description

Extends account-level feature flags by adding a second bigint bitset
column, `feature_flags_ext_2`, while preserving the existing
`flag_shih_tzu` feature check and enable/disable APIs. Existing flags
continue to live on `feature_flags`; future flags can opt into the
extension column through `config/features.yml` metadata.

Fixes
[CW-7238](https://linear.app/chatwoot/issue/CW-7238/feature-flag-extension)

## Type of change

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

## How Has This Been Tested?

- `eval "$(rbenv init -)" && RAILS_ENV=test
POSTGRES_DATABASE=chatwoot_test_31a6 bundle exec rspec
spec/models/concerns/featurable_spec.rb spec/models/account_spec.rb
spec/lib/config_loader_spec.rb
spec/controllers/platform/api/v1/accounts_controller_spec.rb
spec/controllers/super_admin/accounts_controller_spec.rb
spec/enterprise/models/account_spec.rb` - 144 examples, 0 failures
- `eval "$(rbenv init -)" && bundle exec rubocop
app/models/concerns/featurable.rb app/models/account.rb
db/migrate/20260706215758_add_feature_flags_ext_2_to_accounts.rb
spec/models/concerns/featurable_spec.rb spec/models/account_spec.rb
spec/lib/config_loader_spec.rb spec/enterprise/models/account_spec.rb` -
7 files inspected, no offenses detected
- `ruby -ryaml -e "features =
YAML.safe_load(File.read('config/features.yml')); abort unless
features.size == 63; puts features.group_by { |f| f['column'] ||
'feature_flags' }.transform_values(&:size).inspect"` - `{"feature_flags"
=> 63}`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-08 17:38:26 +05:30
d3c588ff10 chore(inbox): re-enable Instagram inbox creation (#14955)
Brings the Instagram channel back in inbox creation and onboarding.
Instagram was temporarily disabled along with WhatsApp embedded signup
in #14943; this re-enables Instagram while keeping WhatsApp embedded
signup and WhatsApp Call inbox creation disabled.

Fixes https://linear.app/chatwoot/issue/CW-7549/enable-instagram

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-08 17:37:46 +05:30
Tanmay Deep SharmaandGitHub f6c18f5225 feat: account calls dashboard index endpoint (#14780)
## Description

Adds a backend endpoint that powers an account-wide calls dashboard,
letting users list and filter all calls in the account.

## Linear Ticket
- https://linear.app/chatwoot/issue/UPM-28/voice-call-dashboard-view

## Type of change

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

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-08 15:31:03 +05:30
Sojan JoseandGitHub 0e07a27c74 fix: enforce inbox limits at model level (#14949)
Fixes https://linear.app/chatwoot/issue/CW-7559/inbox-limit-abuse

## Why

The regular inbox API checked limits in the controller, but WhatsApp
embedded signup creates inboxes through a service using `Inbox.create!`.
That let Enterprise account inbox limits be skipped for embedded signup.

## What this change does

- Adds an Inbox create-time validation hook in OSS and implements the
limit check in the Enterprise Inbox module.
- Removes the duplicate controller/helper limit check so the model is
the single enforcement point.
- Preserves the existing `402 Payment Required` API response for account
inbox limit failures.
- Keeps updates to existing inboxes allowed when an account is already
at its inbox limit.

## Validation

- `bundle exec rspec
spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
spec/enterprise/models/inbox_spec.rb`
2026-07-08 13:18:07 +04:00
ce8c8e9a11 fix: sanitize control characters in team names (#14865)
Team names created via the API could contain control characters (for
example a trailing newline). Because the team-delete confirmation dialog
requires you to retype the team name and matches it against the stored
value, a hidden control character meant the typed name never matched —
leaving the team impossible to delete from the UI. This sanitizes team
names on save so they stay clean and deletable.

#### How to reproduce
1. Create a team via `POST /api/v1/accounts/{account_id}/teams` with
`{"name": "test\n"}`.
2. The team is created with the trailing newline stored in `name`.
3. In **Settings → Teams**, click delete and type the team name to
confirm — the match fails, so the team cannot be deleted.

#### What changed
- `app/models/team.rb`: the existing `before_validation` now strips
control characters and surrounding whitespace before downcasing the
name. Names that reduce to blank (e.g. only newlines/tabs) are rejected
loudly by the existing `presence` validation.
- Fixing at the model layer covers the API and every other create/update
path, rather than relying on the frontend confirm-dialog `.trim()`
(which only handles leading/trailing whitespace, not internal control
characters).

Note: this prevents new malformed names. Any team already saved with a
control character can be made deletable again simply by renaming it (an
update re-runs the same sanitization).

| Input | Stored as | Result |
|---|---|---|
| `"test\n"` | `"test"` | valid, deletable |
| `"te\nst"` (internal) | `"test"` | valid |
| `"\t\n "` (only control/ws) | — | rejected: "Name must not be blank" |
| `"Customer Support"` | `"customer support"` | unchanged behavior |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:04:24 -07:00
Shivam MishraandGitHub 29d0b92f1c fix: captain hours saved metric (#14948)
Reworks the Captain assistant "hours saved" metric so it produces a
believable number. It previously multiplied assistant reply count by
customer reply-wait time, which inflated the figure into the millions of
hours. It now estimates saved time as replies × a fixed ~2 min per-reply
effort assumption. On the overview card the value renders in days once
it passes 100 hours, and the label/hint copy was updated to match.

## How to test
1. Open the Captain assistant overview page.
2. Check the "Time saved" card shows a sensible value (hours, or days
past 100h).
3. Hover the hint and confirm it reflects the per-reply estimate.
2026-07-07 19:16:24 +05:30
a5fcecb3f6 feat: assistant overview page [CW-7408] (#14889)
This PR adds a Captain Assistant **Overview** page to show some KPI
metrics (conversations handled, auto-resolution, handoff, hours saved,
reopen-after-resolve, conversation depth) with trend deltas vs the
previous window, a real knowledge card, and a lazily-loaded, cached LLM
welcome summary.

### Highlights

- **Two contextual banners** on the overview:
- **Inbox banner** — prompts the user to connect an inbox when the
assistant has none, so it can actually do work.
- **Coverage banner** — warns when FAQ coverage is below 85% with more
than 100 responses pending review, linking straight to the pending
queue. Dismissal persists per-assistant for 24h via localStorage.
- **Batched stats builder** (`Captain::AssistantStatsBuilder`) computes
both windows in single FILTER-aggregated scans to cut round trips,
behind new `stats`/`summary` endpoints.
- **Cards included but intentionally left dummy / not rendered yet:**
`ResponseQualityCard` (flagged responses) and `CreditUsageCard` (credit
usage + daily chart). Credits are an account-wide counter with no
per-assistant or daily history, so there is no real data to back them
yet; they ship in the codebase but are not wired into the page.

### Index migration

- Replaces `index_messages_on_sender_type_and_sender_id` with
`index_messages_on_sender_and_created` `(sender_type, sender_id,
created_at)`.
- **Why it helps:** the per-assistant windowed lookups filter `sender_*`
*and* a `created_at` range. The old 2-column index matched every
lifetime row for the assistant and filtered the time slice at the heap
(~89% of rows discarded); adding `created_at` as a range column lets
Postgres scan only the window, and fixes the row-count estimate so the
planner picks a hash join over a nested loop on `reporting_events`.
- **Why dropping the old index is safe:** the new index is a left-prefix
superset `(sender_type, sender_id, ...)`, so every query the old one
served is still served. No code references it by name, and dropping it
keeps write amplification on `messages` neutral. Built/dropped with
`CONCURRENTLY` and `if_not_exists`/`if_exists` guards.


## Preview

<img width="2572" height="1754" alt="CleanShot 2026-06-29 at 22 38
51@2x"
src="https://github.com/user-attachments/assets/3798d09e-7850-48e4-b2cd-508533f15cea"
/>

## Banners

#### Inbox connect alert

<img width="2178" height="612" alt="CleanShot 2026-06-30 at 14 26 55@2x"
src="https://github.com/user-attachments/assets/373c371c-bb7d-4291-a0f9-620673078302"
/>

#### Coverage alert
<img width="2178" height="612" alt="CleanShot 2026-06-30 at 14 25 41@2x"
src="https://github.com/user-attachments/assets/e12d6308-11b6-4ba2-88a2-8a3077dd3e8f"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-07 17:54:13 +05:30
Vishnu NarayananandGitHub 1e7218d439 fix: compare HMAC identity hashes securely (#14945)
This switches widget and public inbox HMAC identity checks from direct
string equality to constant-time comparison with a length guard.

Fixes
[https://linear.app/chatwoot/issue/CW-6937](https://linear.app/chatwoot/issue/CW-6937)
Fixes
[https://linear.app/chatwoot/issue/CW-7464](https://linear.app/chatwoot/issue/CW-7464)
Fixes
[https://linear.app/chatwoot/issue/CW-7227](https://linear.app/chatwoot/issue/CW-7227)
2026-07-07 17:37:22 +05:30
c8bfbadae2 fix: validate identifier hash on widget contact update (#14884)
## Description

The widget contact update endpoint (`PATCH /api/v1/widget/contact`)
applied updates that set an `identifier` without validating the
`identifier_hash`, unlike `set_user`, which already does. This change
runs the same validation on the update path whenever an `identifier` is
supplied, keeping identity validation consistent across the widget
contact endpoints.

Anonymous updates that don't pass an identifier (prechat
name/email/phone and custom attributes) keep working unchanged,
including on inboxes with mandatory identity validation.

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


---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-07 16:40:19 +05:30
Aakash BakhleandGitHub 83dda621c5 feat: default new accounts to captain v2 (#14917)
# Pull Request Template

## Description

defaults new accounts to captain v2

## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and specs

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-07 16:21:51 +05:30
Sony MathewandGitHub eab859cd04 fix: Sanitize query canceled API errors (#14933)
# Pull Request Template

## Description

Message creation API failures caused by PostgreSQL query cancellations
now return a generic retryable error instead of exposing raw database
internals such as `PG::QueryCanceled`, tuple identifiers, or relation
names to customers. Existing validation failures continue to return
their specific validation messages.

Closes
[CW-7538](https://linear.app/chatwoot/issue/CW-7538/do-not-expose-pgquerycanceled-details-in-messages-api-errors)

## Type of change

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

## How Has This Been Tested?

Reproduced the messages API path by raising
`ActiveRecord::QueryCanceled` from `Messages::MessageBuilder` and
verified the response contains the customer-safe localized error without
`PG::QueryCanceled` details.

Validation run locally:

- `bundle exec rspec
spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb`
- `bundle exec rubocop
app/controllers/concerns/request_exception_handler.rb
spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-07 16:20:52 +05:30
Sojan JoseandGitHub b823764199 refactor: optimize agent bot assignment dropdown (#14866) 2026-07-07 14:25:54 +05:30
397ac2e18a chore(inbox): temporarily disable Instagram and WhatsApp embedded signup inbox creation (#14943)
This temporarily turns off inbox creation for channels that rely on
Meta's embedded signup — the WhatsApp Cloud signup popup, Instagram
(OAuth-only), and WhatsApp Call. WhatsApp Cloud inbox creation now falls
back to the manual API-key form, while the Instagram and WhatsApp Call
tiles appear disabled. Both channels are also hidden from the
new-account onboarding flow. Existing inboxes are not affected.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-07 12:18:17 +04:00
Tanmay Deep SharmaandGitHub 11deffdd5d feat: billing brl pix new users (#14617)
## Linear ticket
- https://linear.app/chatwoot/issue/CW-7253/billing-brl-pix-new-users

## Description

New accounts that sign up in Brazilian Portuguese are now billed in BRL
instead of USD. Their Stripe customer is created with a Brazil address
and Portuguese locale (so the Stripe portal offers Real prices and PIX),
and the AI credit top-up flow shows packages priced in the account's
billing currency. Currency support is config-driven, so adding another
currency later is a configuration change rather than a code change.


## Type of change

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

## How Has This Been Tested?

- https://www.loom.com/share/c8d3d08c1b844ed6b820438d4209491a

## Screenshot
<img width="904" height="440" alt="image"
src="https://github.com/user-attachments/assets/6f19fad8-e6af-46ea-b99f-b0265bb9eeec"
/>


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-06 16:35:38 +05:30
8818d276b9 fix(captain): read OpenAI key from InstallationConfig in article search terms (#14915)
generate_article_search_terms still pulled ENV['OPENAI_API_KEY'], left
over from before the Jan 2025 Captain migration moved the key into
InstallationConfig as CAPTAIN_OPEN_AI_API_KEY. Every other Captain LLM
call site got updated then; this one (used by Portal::ArticleIndexingJob
for help center article embedding search terms) didn't, so it sent a
blank bearer token unless you also happened to have the old env var set.

Also drops the stale OPENAI_API_KEY line from .env.example and points to
where the key actually lives now (Super Admin > App Configs > Captain).

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-02 19:29:50 +05:30
Sivin VargheseandGitHub 90b9dba9f9 fix: correct report drilldown drawer position in RTL/LTR (#14919) 2026-07-02 18:44:25 +05:30
Sivin VargheseandGitHub 2a21d075f6 fix: preserve link URLs on channels without hyperlink support (#14912) 2026-07-02 17:23:53 +05:30
Shivam MishraandGitHub b8545019e1 feat: add catch-all failure handling to article writer (#14881)
Help-center onboarding jobs were getting stuck in the `generating` state
indefinitely. `Onboarding::HelpCenterArticleWriterJob` only finalized
the generation counter for two exception types
(`Firecrawl::FirecrawlError`, `ArticleBuildFailed`). Any other error
exhausted Sidekiq's default retries and landed in the dead set without
ever bumping `finished`, leaving state at `total - 1` / `generating`
until the 7-day Redis TTL expired. Observed in production: 4 generations
wedged at exactly `finished = total - 1`, four days after the run, with
no further progress — the onboarding UI showed "generating" the whole
time.


This PR adds a `discard_on StandardError` catch-all to
`HelpCenterArticleWriterJob`, after the existing `retry_on
Firecrawl::FirecrawlError` and `discard_on ArticleBuildFailed` handlers.
ActiveJob matches in declaration order, first match wins, so existing
behavior is unchanged — the catch-all only absorbs errors that
previously fell through to unhandled retry-then-dead-set. It routes
through the same `on_writer_failure` → `finalize` path, so state always
progresses to `completed`.

<details><summary>

##### Script to remove dead jobs

</summary>
<p>

```rb
# One-off: unstick Onboarding::HelpCenterGenerationState keys that are wedged in
# "generating" because a writer job died on an unhandled exception (neither
# FirecrawlError nor ArticleBuildFailed) and exhausted Sidekiq retries without
# ever calling record_article_finished.
#
# These portals have real articles (finished is 1 short of total). Marking them
# "completed" is the honest terminal state: generation is done, one article failed.
#
# Run on a prod box (dry-run first, then REMOVE_DRY_RUN=1):
#   RAILS_ENV=production bundle exec rails runner scripts/help_center_investigation/unstick_generating.rb
#
# To actually write, set REMOVE_DRY_RUN=1 in the environment.

pattern = format(Redis::Alfred::HELP_CENTER_GENERATION, id: '*')
dry_run = ENV['REMOVE_DRY_RUN'].blank?

stuck = []
Redis::Alfred.with do |conn|
  conn.scan_each(match: pattern, count: 1000) do |key|
    h = conn.hgetall(key)
    next unless h['status'] == 'generating'

    gen_id = key.sub('HELP_CENTER_GENERATION::', '')
    stuck << { gen_id: gen_id, total: h['total'], finished: h['finished'], key: key }

    unless dry_run
      conn.hset(key, 'status', 'completed')
      conn.expire(key, Onboarding::HelpCenterGenerationState::TTL)
    end
  end
end

puts "#{dry_run ? '[DRY RUN] ' : ''}Found #{stuck.size} stuck 'generating' states:"
stuck.each do |s|
  puts "  gen=#{s[:gen_id]} total=#{s[:total]} finished=#{s[:finished]} -> #{dry_run ? 'would mark completed' : 'marked completed'}"
end
puts
puts 'Re-run with REMOVE_DRY_RUN=1 to apply.' if dry_run
````

</p>
</details>
2026-07-02 16:17:34 +05:30
6a7ca9dd3b feat: Add report bar drilldown drawer (#14626)
## Description

Adds drilldown support for report bar charts powered by
`ReportContainer`. Clicking a non-zero report bar now opens a right-side
drawer with the conversations or messages that contributed to that
bucket, with each row linking to the underlying conversation and message
rows linking with `messageId`.

This includes a new `GET /api/v2/accounts/:account_id/reports/drilldown`
endpoint, backend drilldown builders/serializers, generic chart click
emission, local drawer state via `useReportDrilldown`, compact drilldown
cards, pagination, stale-response protection, and validation for
unsupported drilldown dimensions.

Fixes # CW-4497

https://linear.app/chatwoot/issue/CW-4497/drill-down-on-agent-conversations-report

## Type of change

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

## How Has This Been Tested?

Ran the focused backend and frontend checks for the drilldown endpoint,
builder, chart click handling, drawer/card UI, API helper, and
stale-response handling.

Here are the screenshots on how it looks like:
<img width="1792" height="1199" alt="Screenshot 2026-06-02 at 11 32
11 PM"
src="https://github.com/user-attachments/assets/6bdb8832-b9df-4bf3-9a2a-beaefe203b6e"
/>
<img width="1791" height="1230" alt="Screenshot 2026-06-02 at 11 32
34 PM"
src="https://github.com/user-attachments/assets/36e92eb7-3208-4855-87f4-0c7f316df54d"
/>
<img width="1784" height="1235" alt="Screenshot 2026-06-02 at 11 32
46 PM"
src="https://github.com/user-attachments/assets/f7a53916-74f2-4622-9305-042e0ac9e877"
/>



## Checklist:

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

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-07-02 16:07:26 +05:30
6c9efc4e92 fix: assign outbound voice call conversation to the calling agent (#14906)
Outbound voice calls were being auto-assigned to the wrong agent. When
an agent placed an outbound call, the conversation was created without
an assignee, so inboxes with auto-assignment enabled would round-robin
it to a different agent instead of keeping it with the person who
actually made the call. This made it hard to tell which agent was on an
active call.

## What changed

- Set the calling agent as the conversation's assignee when creating an
outbound voice call conversation.
- This prevents the generic auto-assignment handler from treating the
conversation as unassigned and reassigning it.
- Added specs covering the assignment, including a regression case with
inbox auto-assignment enabled.

**Note:** this applies to newly placed calls; it does not retroactively
fix conversations that were already mis-assigned.

---------

Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-07-02 16:06:11 +05:30
Sivin VargheseandGitHub 34d8741b9b feat: drag to reorder help center articles across pages (#14910) 2026-07-02 15:47:01 +05:30
7bf76057c2 fix: SLA handling for blocked contacts (#14861)
# Pull Request Template

## Description

Blocked contacts are now excluded from SLA assignment, processing,
reports, and conversation SLA UI while they remain blocked. Existing SLA
records are preserved, and SLA behavior resumes if the contact is
unblocked.

Fixes
https://linear.app/chatwoot/issue/CW-7435/sla-should-not-trigger-for-blocked-contacts

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/enterprise/models/conversation_spec.rb
spec/enterprise/models/applied_sla_spec.rb
spec/enterprise/services/enterprise/action_service_spec.rb
spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb
spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb
spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/presenters/conversations/event_data_presenter_spec.rb` —
78 examples, 0 failures
- `bundle exec rubocop
enterprise/app/controllers/api/v1/accounts/applied_slas_controller.rb
enterprise/app/jobs/sla/process_account_applied_slas_job.rb
enterprise/app/models/applied_sla.rb
enterprise/app/models/enterprise/concerns/conversation.rb
enterprise/app/presenters/enterprise/conversations/event_data_presenter.rb
enterprise/app/services/enterprise/action_service.rb
enterprise/app/services/sla/evaluate_applied_sla_service.rb
lib/tasks/apply_sla.rake
spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb
spec/enterprise/models/applied_sla_spec.rb
spec/enterprise/models/conversation_spec.rb
spec/enterprise/presenters/conversations/event_data_presenter_spec.rb
spec/enterprise/services/enterprise/action_service_spec.rb
spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb` — no
offenses
- `pnpm exec vitest --no-watch --no-cache --no-coverage
app/javascript/dashboard/components/widgets/conversation/specs/ConversationCard.spec.js`
— 2 tests passed
- `pnpm exec eslint
app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue
app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue
app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue
app/javascript/dashboard/components/widgets/conversation/specs/ConversationCard.spec.js`
— passed with existing raw-text warnings in `ConversationHeader.vue`
- `git diff --cached --check` — clean

## Checklist:

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

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-02 14:16:29 +05:30
Tanmay Deep SharmaandGitHub a3c7f3b204 fix: finalize WhatsApp calls when terminate webhook overtakes connect (#14836)
## Description

Some inbound WhatsApp calls stayed stuck in "ringing" forever. When a
caller hung up within ~1s of dialing, Meta delivered the terminate
webhook before the connect webhook. The terminate arrived with no call
record yet and was dropped, then connect created the call in ringing
with nothing left to close it. These calls now correctly land as missed
(no_answer), and an agent who taps Accept on a call that already ended
gets a clean "call ended" instead of a generic error.

## Type of change

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

## How Has This Been Tested?

- local UI testing

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-02 13:58:36 +05:30
49b0ab0e1f fix: Consider business hours when computing SLA breaches (#13392)
- Fixes SLA breach computation to respect the "Only during business
hours" setting
- Backend now pre-computes SLA deadlines, simplifying frontend logic

## How it works

Before: SLA deadlines were calculated using wall-clock time, ignoring
business hours.

After: When an SLA policy has "Only during business hours" enabled and
the inbox has working hours configured, the deadline is calculated by
adding threshold time only during business hours.


**How you check if a conversation has a SLA hit or miss?**

<img width="474" height="510" alt="Screenshot 2026-01-28 at 7 06 53 PM"
src="https://github.com/user-attachments/assets/54ec8581-18b8-45c6-a356-de8c778ea78d"
/>


**Example:**
- Conversation created: Friday 4:30 PM
- FRT threshold: 1 hour
- Business hours: Mon-Fri 9 AM - 5 PM

| | Breach time |
|--|--|
| Before | Friday 5:30 PM |
| After | Monday 9:30 AM |

## Test plan

- [x] Create an SLA policy with "Only during business hours" enabled
- [x] Configure inbox with business hours (e.g., Mon-Fri 9-5)
- [x] Conversation created during business hours
  - Create a conversation on Wednesday 10:00 AM UTC
- Expected: FRT deadline shows Wednesday 12:00 PM UTC (2 business hours
later)
- [x] Conversation created before business hours
  - Create a conversation on Wednesday 7:00 AM UTC
- Expected: FRT deadline shows Wednesday 11:00 AM UTC (counting starts
at 9 AM)
- [x] Conversation created after business hours
  - Create a conversation on Wednesday 6:00 PM UTC
- Expected: FRT deadline shows Thursday 11:00 AM UTC (counting starts
next day 9 AM)
- [x] Conversation created on weekend
  - Create a conversation on Saturday 10:00 AM UTC
  - Expected: FRT deadline shows Monday 11:00 AM UTC (skips weekend)
- [x] Threshold spans weekend
  - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT
- Expected: FRT deadline shows Monday 10:00 AM UTC (1h Friday + 1h
Monday)
- [x] SLA without business hours
  - Create an SLA policy with only_during_business_hours: false
  - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT
  - Expected: FRT deadline shows Friday 6:00 PM UTC (wall-clock time)
- [x] All Day marked as closed_all_day
  - Create a conversation on Tuesday 4:00 PM UTC with 2-hour FRT
  - Expected: FRT deadline shows Thursday 10:00 AM UTC
- [x] All Day marked as open_all_day
  - Create a conversation on Saturday 10:00 AM UTC with 2-hour FRT
  - Expected: FRT deadline shows Saturday 12:00 PM UTC 
- [x] UI displays correct countdown
  - Verify conversation card shows correct SLA timer
  - Verify timer shows flame icon when breached
  - Verify timer shows alarm icon when within threshold
  - Time updates automatically when time passes
- [x] Verify the breach with a different timezone than your local
timezone

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-07-02 13:33:22 +05:30
Aakash BakhleandGitHub 926a9d8a69 fix(captain): default temperature to 0.5 and remove UI control (#14879)
# Pull Request Template

## Description

- Default temperature to 0.5 and remove UI control
- No migrations needed for existing accounts, their current settings are
preserved

## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and spec

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-30 15:14:03 +05:30
Aakash BakhleandGitHub 9caceea858 fix(deps): update msgpack for CVE-2026-54522 (#14898)
# Pull Request Template

## Description

This updates the locked `msgpack` gem from `1.8.0` to `1.8.3` so the
bundle-audit check no longer flags CVE-2026-54522. The upgrade stays
within the existing transitive dependency constraints used by `bootsnap`
and `datadog`.

Fixes:
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114757/workflows/f8c7b37f-27d5-45d4-9f1b-1d1782ebc4e3/jobs/162250

## Type of change

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

## How Has This Been Tested?

- `eval "$(rbenv init -)" && bundle exec bundle audit update && bundle
exec bundle audit check -v`
- `eval "$(rbenv init -)" && bundle exec rspec
spec/listeners/action_cable_listener_spec.rb`
- `eval "$(rbenv init -)" && RUBOCOP_CACHE_ROOT=tmp/rubocop_cache bundle
exec rubocop --no-server Gemfile`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-30 14:56:34 +05:30
Shivam MishraandGitHub 8670f66155 feat: broaden search scope for help center generation (#14880)
**Broaden the Firecrawl `map` search term list so help-center onboarding
doesn't skip sites with non-standard docs paths**

Help-center onboarding was skipping ~70% of new accounts, and ~60% of
those skips came from a single failure: `"map returned no links"`. The
root cause was an overly narrow hardcoded search query passed to
Firecrawl's `map` endpoint.

The curator (`Onboarding::HelpCenterCurator`) calls `Firecrawl.map(url,
search: MAP_SEARCH)` to discover candidate pages before the LLM curation
step. `MAP_SEARCH` was hardcoded to `"docs help support faq"` — a 4-term
list that only matched sites whose help content sat at `/docs`, `/help`,
`/support`, or `/faq`. Sites using `/resources`, `/guides`, `/kb`,
`/articles`, `/handbook`, `/learn`, `/how-to`, `/tutorial`,
`/troubleshooting`, or a docs subdomain found nothing, so the job raised
`CurationSkipped` and left the portal empty.

Firecrawl's `search` param is a grep-style substring filter across URL,
title, and description (not a semantic query), so the fix is to broaden
the term list rather than drop it. The LLM curator downstream
(`Captain::Llm::HelpCenterCurationService`) already filters returned
links by quality — it has the full URL-path-priority prompt and a
25-article hard ceiling — so a wider crawl net is safe and doesn't
change the final article quality bar.

**What changed**

- `enterprise/app/services/onboarding/help_center_curator.rb`:
`MAP_SEARCH` broadened from `"docs help support faq"` to a 13-term list
covering common help-content path hints. Comment added documenting why
the term list exists and that the LLM curator does the real filtering.
2026-06-30 14:37:15 +05:30
Sivin VargheseandGitHub 2767bd434b fix: Show a clear error when message translation fails (#14891) 2026-06-30 14:23:47 +05:30
Aakash BakhleandGitHub ce2e10e89e fix: tighten captain v2 (#14883)
# Pull Request Template

## Description

Tightens v2 prompt and config to match v1

## Type of change

Please delete options that are not relevant.

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


## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-30 14:20:54 +05:30
Sivin VargheseandGitHub 56275b750e fix: respect companies feature flag for auto-association (#14886)
# Pull Request Template

## Description

This PR stops new contacts from getting an auto-assigned company name
when the Companies feature is disabled.

Since #14496, email-domain company auto-association also updates a
contact's `company_name`. However, the callback isn't gated behind the
Companies feature flag, so accounts without the feature enabled still
auto-create companies and overwrite any `company_name` provided via the
SDK/`setUser`.

This PR gates `should_associate_company?` behind
`account.feature_enabled?('companies')`, so auto-association only runs
when the Companies feature is enabled.

Fixes
https://linear.app/chatwoot/issue/CW-7462/setuser-overwrites-contact-company-name-for-accounts-that-dont-use

## Type of change

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


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-29 17:23:31 -07:00
299bc6c0a4 fix: recover from stale LeadSquared lead ids on activity sync (#14818)
LeadSquared sync now recovers automatically when a contact's cached lead
has been deleted or merged on the LeadSquared side. Previously the stale
lead id was never cleared, so every new conversation or contact update
for that contact failed with "Lead not found"
(`MXInvalidEntityReferenceException`) indefinitely.

## What changed
- Activity sync: on a "Lead not found" error while posting a
conversation/transcript activity, clear the cached `leadsquared_id`,
re-resolve the contact to a fresh lead, and retry the activity once
(guarded against loops and duplicate leads).
- Contact sync: on the same error while updating an existing lead, clear
the cached id and create a fresh lead instead.
- Fix `get_lead_id` to actually return early for unidentifiable contacts
(the guard previously fell through).

## How to reproduce
1. For a LeadSquared-enabled account, point a contact's cached lead id
at a lead that no longer exists in LeadSquared.
2. Update the contact, or create/resolve a conversation for it.
3. Before: the sync fails repeatedly with "Lead not found" and never
self-corrects. After: the stale id is cleared, a fresh lead is
resolved/created, and subsequent syncs reuse the healed id.

---------

Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-06-29 16:38:08 +05:30
7522457740 feat: v2 - generations get trace level attributes (#14878)
# Pull Request Template

~~Note: merge only after https://github.com/chatwoot/ai-agents/pull/74
has been merged~~

## Description

Before:
<img width="436" height="617" alt="image"
src="https://github.com/user-attachments/assets/fd5be8dc-abab-4e01-b251-366648b998ea"
/>


After:
<img width="446" height="577" alt="image"
src="https://github.com/user-attachments/assets/8d96d2f2-c126-4cca-b3a2-f2a62b204adf"
/>


<img width="441" height="558" alt="image"
src="https://github.com/user-attachments/assets/89da4157-48a5-4fdc-9c67-9bf987e31638"
/>



## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and specs

## Checklist:

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-29 13:50:17 +05:30
Sony MathewandGitHub e4ef2de8c8 fix(security): Update crass to 1.0.7 (#14882)
## Description

Updates the transitive `crass` dependency from `1.0.6` to `1.0.7` so the
bundle-audit security check no longer flags the Crass denial-of-service
advisories published on June 25, 2026. `crass` is pulled in through
`rails-html-sanitizer -> loofah`, and this change only updates the
resolved lockfile version.

Fixes # N/A

## Type of change

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

## How Has This Been Tested?

- `bundle exec bundle audit update && bundle exec bundle audit check -v`
- `bundle exec rspec spec/mailboxes/mailbox_helper_spec.rb
spec/mailboxes/reply_mailbox_spec.rb
spec/mailboxes/imap/imap_mailbox_spec.rb
spec/models/channel/telegram_spec.rb
spec/lib/integrations/slack/send_on_slack_service_spec.rb
spec/lib/integrations/slack/update_slack_message_service_spec.rb
spec/presenters/html_parser_spec.rb`
- `bundle exec rubocop Gemfile`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-29 13:47:57 +05:30
Sojan JoseandGitHub d0b1c055e8 chore: Track cloud plan activation conversions (#14834)
## Summary
- track cloud plan activation conversions when an attributed account
moves from the configured default cloud plan to a paid plan
- use the Stripe webhook event time as the activation timestamp so the
30-day signup attribution window reflects the actual upgrade event
- send the Stripe subscription amount and currency for the conversion
value
- mark the account attribution after enqueueing so later plan updates do
not send duplicate activation conversions

## Notes
- Marketing tracker: https://linear.app/chatwoot/issue/MAR-113
- Cloud implementation: https://linear.app/chatwoot/issue/LEA-34
- Stripe billing stays responsible for subscription state and value
calculation.
- Cloud plan activation conversion tracking is handled by a small
dedicated service that owns the activation rule, duplicate marker, and
conversion enqueue.
- Website attribution cookie capture remains separate in the marketing
attribution service.
- There is no frontend change and no new user-facing configuration.
- Conversion upload still no-ops outside Chatwoot Cloud and when
attribution has no supported click identifier.
2026-06-25 18:41:43 -07:00
Sony MathewandGitHub cf134deb37 fix: Preserve Captain LLM defaults (#14858)
# Pull Request Template

## Description

Adjusts the Captain LLM feature defaults after feature routing so the
defaults stay intentional and avoid unintended high-cost model upgrades.
Assistant, copilot, and onboarding content generation now default to
`gpt-4.1`; audio transcription keeps `gpt-4o-mini-transcribe` as the
default while exposing `whisper-1` as an available account override
option.

Related: https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

- `ruby -ryaml -e 'yaml = YAML.load_file("config/llm.yml"); features =
yaml.fetch("features"); models = yaml.fetch("models"); features.each {
|name, cfg| missing = Array(cfg["models"]) - models.keys; raise
"#{name}: missing #{missing.join(",")}" if missing.any?; raise "#{name}:
default not in models" unless
Array(cfg["models"]).include?(cfg["default"]) }'`
- `node -e
"JSON.parse(require('fs').readFileSync('app/javascript/dashboard/i18n/locale/en/settings.json',
'utf8'))"`
- `pnpm exec prettier --check
app/javascript/dashboard/i18n/locale/en/settings.json`
- `bundle exec rspec spec/lib/llm/feature_router_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/models/concerns/agentable_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/models/account_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/controllers/super_admin/accounts_controller_spec.rb`
- `bundle exec rspec
spec/enterprise/services/captain/llm/article_translation_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb`
- `RUBOCOP_CACHE_ROOT=/private/tmp/rubocop_cache bundle exec rubocop
enterprise/app/services/captain/llm/article_translation_service.rb
enterprise/app/services/messages/audio_transcription_service.rb
spec/enterprise/services/captain/llm/article_translation_service_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 21:35:09 +05:30
Sony MathewandGitHub b8b62ad0f1 feat: Harden model override preferences (5/6) (#14846)
## Description

Hardens the Captain model override preferences API so account-level
overrides follow the same feature-router contract used by runtime LLM
calls. The API now permits model and feature keys from `llm.yml`,
removes blank model overrides, rejects invalid saved model combinations,
and returns each feature's effective model, provider, and source for UI
clients.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

Verified the account preferences API and account model validation
behavior for valid overrides, invalid model values, unknown feature
keys, blank override removal, and effective model/provider/source
payload metadata.

- `eval "$(rbenv init -)" && bundle exec rspec
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/models/account_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/lib/llm/feature_router_spec.rb`
- `eval "$(rbenv init -)" && bundle exec rubocop
app/controllers/api/v1/accounts/captain/preferences_controller.rb
app/models/concerns/account_settings_schema.rb
app/models/concerns/captain_featurable.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/models/account_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/lib/llm/feature_router_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:38:11 +05:30
Sony MathewandGitHub 4e26c5b4bb feat: Route system LLM jobs (4/6) (#14843)
## Description

Routes the remaining system-only and legacy-sensitive LLM jobs through
feature-level model configuration, while preserving system credential
usage and usage-accounting behavior. This adds dedicated defaults for
help center article generation, onboarding content generation, query
translation, transcription, and search embeddings so these flows can be
configured per account without falling back to installation-wide model
settings.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

Verified the feature routing defaults and account overrides for the
touched Captain/system LLM paths, including the legacy OpenAI
transcription and paginated FAQ services.

- `eval "$(rbenv init -)" && bundle exec rspec
spec/lib/captain/base_task_service_spec.rb spec/lib/llm/models_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/enterprise/services/captain/llm/paginated_faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/enterprise/services/onboarding/help_center_article_builder_spec.rb
spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb`
- `eval "$(rbenv init -)" && bundle exec rubocop
app/controllers/api/v1/accounts/captain/preferences_controller.rb
app/models/concerns/account_settings_schema.rb
lib/captain/base_task_service.rb
enterprise/app/services/captain/llm/article_translation_service.rb
enterprise/app/services/captain/llm/article_writer_service.rb
enterprise/app/services/captain/llm/embedding_service.rb
enterprise/app/services/captain/llm/help_center_curation_service.rb
enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
enterprise/app/services/captain/llm/translate_query_service.rb
enterprise/app/services/captain/llm/widget_tagline_service.rb
enterprise/app/services/captain/onboarding/website_analyzer_service.rb
enterprise/app/services/messages/audio_transcription_service.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/lib/captain/base_task_service_spec.rb`
- `ruby -e "require 'yaml'; config = YAML.load_file('config/llm.yml');
%w[document_faq_generation help_center_article_generation
onboarding_content_generation help_center_query_translation
audio_transcription help_center_search].each { |feature| abort(%(missing
#{feature})) unless config.dig('features', feature) }; abort('wrong
article default') unless config.dig('features',
'help_center_article_generation', 'default') == 'gpt-5.2'; puts 'llm.yml
ok'"`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:37:45 +05:30
Sony MathewandGitHub 43e0f8a141 feat: Route Enterprise LLM services (3/6) (#14841)
# Pull Request Template

## Description

Routes Enterprise assistant, copilot, FAQ, contact memory,
action-classifier, and false-promise detector LLM paths through
feature-specific model resolution. `Llm::BaseAiService` now accepts
feature/account context and uses `Llm::FeatureRouter` when that context
is present, while retaining the installation-model fallback for
unmigrated callers. This also adds a `document_faq_generation` feature
default for generative FAQ/document content.

Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models
Depends on #14840

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/lib/llm/models_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/captain/copilot/chat_service_spec.rb
spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
passed with 112 examples, 0 failures.
- `bundle exec rspec spec/models/concerns/captain_featurable_spec.rb
spec/models/account_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/lib/llm/feature_router_spec.rb` passed with 87 examples, 0
failures.
- `bundle exec rubocop enterprise/app/services/llm/base_ai_service.rb
enterprise/app/services/captain/copilot/chat_service.rb
enterprise/app/services/captain/llm/assistant_chat_service.rb
enterprise/app/services/captain/llm/faq_generator_service.rb
enterprise/app/services/captain/llm/conversation_faq_service.rb
enterprise/app/services/captain/llm/contact_notes_service.rb
enterprise/app/services/captain/llm/contact_attributes_service.rb
enterprise/app/services/captain/llm/assistant_action_classifier_service.rb
enterprise/app/services/captain/llm/assistant_false_promise_service.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/captain/copilot/chat_service_spec.rb
spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
passed with no offenses.
- `bundle exec ruby -e "require 'yaml'; config =
YAML.load_file('config/llm.yml'); abort('missing
document_faq_generation') unless config.dig('features',
'document_faq_generation'); abort('missing default') unless
config.dig('features', 'document_faq_generation', 'default'); puts
'llm.yml ok'"` passed.
- `git diff --check` passed.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:03:07 +05:30
Sony MathewandGitHub 91d8a4e2a3 feat: Route reply box LLM tasks (2/6) (#14840)
# Pull Request Template

## Description

Routes OSS reply-box and small Captain tasks through feature-specific
LLM model resolution. Rewrite, reply suggestion, summary, follow-up, and
CSAT utility analysis now resolve through the `editor` feature; label
suggestion resolves through `label_suggestion`. Existing credentials,
account OpenAI hook behavior, and instrumentation event names remain
unchanged.

Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models
Depends on #14839

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/lib/captain/base_task_service_spec.rb
spec/lib/captain/rewrite_service_spec.rb
spec/lib/captain/reply_suggestion_service_spec.rb
spec/lib/captain/summary_service_spec.rb
spec/lib/captain/label_suggestion_service_spec.rb
spec/lib/captain/csat_utility_analysis_service_spec.rb
spec/lib/captain/follow_up_service_spec.rb` passed with 81 examples, 0
failures.
- `bundle exec rubocop lib/captain/base_task_service.rb
lib/captain/rewrite_service.rb lib/captain/reply_suggestion_service.rb
lib/captain/summary_service.rb lib/captain/label_suggestion_service.rb
lib/captain/csat_utility_analysis_service.rb
lib/captain/follow_up_service.rb
enterprise/lib/enterprise/captain/reply_suggestion_service.rb
spec/lib/captain/base_task_service_spec.rb
spec/lib/captain/rewrite_service_spec.rb
spec/lib/captain/reply_suggestion_service_spec.rb
spec/lib/captain/summary_service_spec.rb
spec/lib/captain/label_suggestion_service_spec.rb
spec/lib/captain/csat_utility_analysis_service_spec.rb
spec/lib/captain/follow_up_service_spec.rb` passed with no offenses.
- `git diff --check` passed.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:01:05 +05:30
Sony MathewandGitHub 8b977b35a8 feat: Add LLM feature router (1/6) (#14839)
## Description

Adds the foundation for feature-specific LLM model routing so Captain AI
features can resolve their effective provider/model from code defaults
and account-level overrides. This fixes the provider metadata key in
`config/llm.yml`, adds `Llm::FeatureRouter`, and routes existing
`CaptainFeaturable` model defaults through the shared resolver.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/lib/llm/models_spec.rb
spec/lib/llm/feature_router_spec.rb
spec/models/concerns/captain_featurable_spec.rb` - 23 examples, 0
failures
- `bundle exec rubocop lib/llm/models.rb lib/llm/feature_router.rb
app/models/concerns/captain_featurable.rb spec/lib/llm/models_spec.rb
spec/lib/llm/feature_router_spec.rb
spec/models/concerns/captain_featurable_spec.rb` - no offenses
- `bundle exec ruby -e "require 'yaml'; config =
YAML.load_file('config/llm.yml'); abort('missing providers') unless
config['providers']; abort('missing models') unless config['models'];
abort('missing features') unless config['features']; puts 'llm.yml ok'"`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:00:07 +05:30
62cbeae95f feat: onboarding inboxes UI (#14565)
After entering their account details, new admins land on an **Inbox
setup** screen that shows what we've already set up for them and lets
them connect their conversation channels without leaving onboarding. It
surfaces the auto-created live chat widget (and Help Center on
Enterprise), highlights channels detected from their website, and offers
a **View all** dialog to connect any supported channel inline.

### Channel status

| Channel | How it connects | Status | PR |
|---|---|---|---|
| Live chat (Website) | Auto-created during setup |  Done |
https://github.com/chatwoot/chatwoot/pull/14314 |
| WhatsApp | Meta embedded signup |  Done |
https://github.com/chatwoot/chatwoot/pull/14619 |
| Facebook | Login + page picker |  Done |
https://github.com/chatwoot/chatwoot/pull/14619 |
| Instagram | OAuth redirect |  Done |
https://github.com/chatwoot/chatwoot/pull/14568 |
| TikTok | OAuth redirect |  Done |
https://github.com/chatwoot/chatwoot/pull/14569 |
| LINE | Inline credential form |  Done | — |
| Telegram | Inline credential form |  Done | — |
| Gmail / Outlook | OAuth (email) | ⚠️ Disabled — coming in a follow-up
| https://github.com/chatwoot/chatwoot/pull/14567 |
| SMS / API / Voice / Other email | — |  Unavailable | — |

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-25 14:54:07 +05:30
7c7459b734 fix(whatsapp): override webhook at phone number level (#13817)
## Description

Move WhatsApp webhook callback override from WABA level to phone number
level, allowing multiple phone numbers on the same WABA to have
independent callback URLs.

## Type of change

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

## How Has This Been Tested?

- Connect a WhatsApp Cloud inbox via embedded signup
- Verify webhook setup succeeds and messages are received
- Connect a second phone number on the same WABA — both should receive
messages independently
- Delete an inbox and verify only that phone number's override is
cleared

## Checklist:

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

---------

Co-authored-by: tds-1 <tds-1@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-25 14:49:09 +05:30
Sojan JoseandGitHub 0849d2e070 chore: Track cloud signup conversions (#14852)
## Summary
- enqueue `cloud_signup` conversion tracking after website attribution
is stored on a new cloud account
- keep authenticated add-workspace requests out of
attribution/conversion tracking
- reuse the existing marketing conversion tracking service and config
already available on `develop`

## Notes
- This PR only wires the signup path.
- Billing/plan activation conversion tracking is intentionally left out
for a separate rollout.
- The conversion service still no-ops outside Chatwoot Cloud and when
stored attribution has no supported click identifier.
2026-06-24 20:04:42 -07:00
Sojan JoseandGitHub cd1a214d98 chore: Add marketing conversion tracking foundation (#14851)
## Summary

Adds the minimal foundation for Cloud marketing conversion tracking:

- locked internal installation config for conversion tracking
credentials and event mappings
- generic background job and service for uploading conversion events
from stored attribution
- focused coverage for Cloud gating, click-id selection, payload shape,
and optional conversion value

This PR intentionally does not wire signup or plan activation yet. The
next step is to validate the service from Rails console against existing
attributed accounts, then add the event hooks in a follow-up PR.

## Notes

The config remains locked and is surfaced under the Internal settings
group next to the existing Cloud plan configuration. The service assumes
the locked config is present and valid; config mistakes should surface
instead of being silently ignored.
2026-06-24 18:27:02 -07:00
f9cc702030 fix(help-center): documentation layout on custom domain root, locale (#14850)
Help centers using the documentation layout now render correctly in two
cases that previously fell back to the wrong output. Opening a portal at
its custom-domain root (e.g. docs.example.com/ ) now shows the full
documentation home
in place instead of the classic layout, and portals whose locale is a
region variant that Chatwoot doesn't ship a
translation for (e.g. th_TH , fr_ML ) now show their categories in the
sidebar on article pages.

Closes
https://linear.app/chatwoot/issue/CW-7437/portal-layout-is-not-properly-working-in-custom-domain

## How to test

1. Create a portal with the documentation layout and a custom domain
(e.g. example.chat.test ), with at least one
category containing a published article.
2. Visit the custom-domain root ( http://example.chat.test:3000/ ) → it
should show the full documentation home
(sidebar, topbar), not the classic layout, with no redirect.
3. Set the portal's locale to a region variant Chatwoot doesn't
translate, e.g. th_TH , with categories/articles
under that locale.
4. Open an article → the sidebar should list the article's category and
sibling articles. Before the fix the sidebar
was empty for these locales.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:12:55 -07:00
74db16158d feat: migrate dyte integration to cloudflare realtimekit (#14752)
Dyte is sunsetting its existing infrastructure after the Cloudflare
acquisition, so this migrates Chatwoot’s video call integration to
Cloudflare RealtimeKit.

The integration now uses Cloudflare Account ID, RealtimeKit App ID, and
a Cloudflare API token with Realtime Admin permissions. Meeting creation
and participant token generation now call Cloudflare’s RealtimeKit APIs,
while the existing Chatwoot call experience remains unchanged for agents
and customers.

This also adds setup-time credential validation, so admins get clearer
errors when the API token is invalid, the Cloudflare account or
permissions are incorrect, or the RealtimeKit App ID does not belong to
the selected account.

Fixes
https://linear.app/chatwoot/issue/PLA-176/migrate-dyte-integration-to-cloudflare-realtimekit

**How to test**

1. Go to Settings → Integrations → Cloudflare RealtimeKit.
2. Add a Cloudflare Account ID, RealtimeKit App ID, and API token with
Realtime Admin permissions.
3. Confirm the integration saves successfully with valid credentials.
4. Try invalid credentials and confirm the error identifies whether the
token, account/permissions, or app ID is wrong.
5. Start a video call from a conversation and confirm the RealtimeKit
meeting opens.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-24 11:04:08 +04:00
Sivin VargheseandGitHub e8edc9ebf5 feat: allow users to set an icon or emoji for teams (#14767) 2026-06-23 20:53:31 +05:30
Vishnu NarayananandGitHub e785620921 feat: read structured X-Chatwoot-* headers for mobile session metadata (#14762)
Phase 2 of [INF-75](https://linear.app/chatwoot/issue/INF-75). Follows
up on [#14753](https://github.com/chatwoot/chatwoot/pull/14753) (Phase 1
UA pattern fallback, already merged).

When the request carries an \`X-Chatwoot-Client-Name\` header,
\`UserSessionTrackingService\` now prefers the five structured
\`X-Chatwoot-*\` headers over the User-Agent for populating the
\`user_sessions\` row:

| Header | Mapped column |
|---|---|
| \`X-Chatwoot-Client-Name\` | \`browser_name\` |
| \`X-Chatwoot-Client-Version\` | \`browser_version\` |
| \`X-Chatwoot-Device-Model\` | \`platform_name\` |
| \`X-Chatwoot-Platform-Version\` | \`platform_version\` |
| \`X-Chatwoot-Platform\` (+ model) | \`device_name\` (\`iPhone\` /
\`iPad\` / \`Android\`) |

When the header is absent or blank, the existing \`Browser.new\` path
runs, with the Phase 1 legacy UA fallback still acting as the floor.
Real browsers are untouched.

A follow-up PR in
[chatwoot-mobile-app](https://github.com/chatwoot/chatwoot-mobile-app)
will start sending these headers from the React Native build. Until that
ships, this PR is a no-op for production traffic, so it can land
independently.

Fixes INF-75.
2026-06-23 20:08:27 +05:30
Aakash BakhleandGitHub 0af9900be2 fix: model for false promise harness (#14824)
fixes model for false promise harness
2026-06-23 18:42:34 +05:30
Aakash BakhleandGitHub 84fda8a323 fix(captain): move false promise harness setting to account (#14823)
# Pull Request Template

## Description

moves the harness to account settings rather than assistant settings

## Type of change
refactor

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally and specs

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-23 16:55:42 +05:30
Aakash BakhleandGitHub 1c7e60880d fix: add harness on false promises (#14672)
# Pull Request Template

## Description


https://linear.app/chatwoot/issue/AI-179/false-promise-soft-handoff-guard

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

Against negative cases identified by evals

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-23 16:30:14 +05:30
Aakash BakhleandGitHub 8e27ca4282 fix(firecrawl): get url in case sourceUrl is not available (#14820)
# Pull Request Template

## Description

Fixes inconsistency by adding a fallback to use `url` instead of
`sourceUrl` when Firecrawl returns empty `sourceUrl` in production.

We switched to v2 endpoints of Firecrawl in
https://github.com/chatwoot/chatwoot/pull/14624 and it worked locally,
but seems to fail for some cases in production.

## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-23 13:40:31 +05:30
ee5e20551d fix(facebook): handle messenger post attachment type (#14813)
Facebook Messenger messages that share a post no longer break message
ingestion. Following the recent sticker change, Meta now sends a shared
post as a new `post` attachment type. Chatwoot didn't recognise `post`
as a valid attachment file type, so the webhook job crashed and the
message — and any others in the same batch — failed to sync. Shared
posts now appear in the conversation as a fallback link (title + URL),
the same way `share` attachments already do.

This is the same class of bug as #14793 (sticker), just a different
attachment type.

#### Root cause

`post` was neither skipped as unsupported nor mapped to a valid
`file_type`, so `attachment_params` produced `file_type: :post`. The
`Attachment#file_type` enum has no `post` value, so it raised
`ArgumentError: 'post' is not a valid file_type`, crashing
`Webhooks::FacebookEventsJob`.

#### Fix

The Facebook builder already maps `share -> :fallback` because shared
posts point to facebook.com page URLs rather than downloadable media. A
`post` is the same kind of attachment, so map both `share` and `post` to
`fallback` (stores the title/link, no download attempt). Also read the
fallback title from `payload.title`, since the post payload nests it
there rather than at the top level.

## How to reproduce

1. Connect a Facebook Page inbox.
2. Share a Facebook post into Messenger to that page.
3. Before this change: `Webhooks::FacebookEventsJob` raises
`ArgumentError: 'post' is not a valid file_type` and the message is
dropped.
4. After this change: the shared post shows up in the conversation as a
fallback link.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:04 +04:00
df79c0bbde feat: exclude stale conversations via assignment policy age threshold (#14766)
## Linear ticket
-
https://linear.app/chatwoot/issue/CW-7137/assignment-v2-backlog-flush-overloads-agents

## Description

Assignment policies now skip stale unassigned conversations
automatically. Each policy carries an age threshold (defaults to 7
days), so auto-assignment only picks up recent backlog instead of
draining very old, forgotten conversations. The threshold is
configurable per policy and can be cleared to assign conversations
regardless of age. Previously this control existed only on Enterprise
capacity policies (`exclude_older_than_hours`); it now lives on the
assignment policy itself, so every V2 inbox benefits without needing a
capacity policy.

## Type of change

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

## How Has This Been Tested?

- Local UI flows

## Screenshots? 

<img width="645" height="822" alt="image"
src="https://github.com/user-attachments/assets/ec58db86-c1fa-4f9e-be87-2e24ff02e077"
/>


## Checklist:

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-23 12:22:45 +05:30
647cfc2d83 feat: distinguish agent-declined calls with a rejected status (#14783)
## Linear ticket
-
https://linear.app/chatwoot/issue/CW-7374/agent-declined-voice-calls-miscounted-as-failed

## Description

Agent rejections were stored with status: failed, so call reports lumped
deliberate declines together with real technical failure.

Fix: give declines their own terminal rejected status (Twilio + WhatsApp
paths), keeping end_reason: agent_rejected. Genuine provider/network
failures stay failed. Frontend renders declines exactly as before;
existing rows backfilled via migration.

## Type of change

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

## How Has This Been Tested?

- Tested on UI

## Checklist:

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-23 12:22:29 +05:30
Sivin VargheseandGitHub 961ec86ba9 chore: support custom variables in auto-resolution message (#14782)
# Pull Request Template

## Description

This PR adds support for custom variables in auto-resolution messages
within Conversation Workflows.

Agents can insert variables (e.g. `{{contact.name}}`) into the custom
auto-resolution message using the variable picker by typing `{{`. These
variables are resolved with actual conversation data when the
auto-resolution message is sent.

The field uses the existing editor with the formatting toolbar hidden,
since auto-resolution messages are delivered across all channels,
including SMS, where formatting isn't supported. Existing message
content is preserved as-is, and typing `{{` opens the variable picker
for quick insertion.

Fixes
https://linear.app/chatwoot/issue/CW-7369/support-custom-variables-in-conversations-workflow-text-fields

## Type of change

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

## How Has This Been Tested?

### Screenshots
<img width="1262" height="764" alt="image"
src="https://github.com/user-attachments/assets/e43be22c-f86e-436a-9fdd-5bcb73c61ca3"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-23 11:35:52 +05:30
Sojan JoseandGitHub e86222034e fix(auth): record attribution for oauth signups (#14796)
Google OAuth signups now persist the same first-party attribution
cookies as email signups on Chatwoot Cloud.

This keeps attribution capture owned by the website and reuses the
existing Enterprise-only account attribution service. The OAuth callback
only records the already-shaped first-touch and last-touch cookie
payload after a new account is created.

## What changed

- Added Enterprise-only attribution persistence to the Google OAuth
signup account creation path.
- Reuses `Internal::Accounts::MarketingAttributionService`.
- Keeps attribution best-effort so failures do not interrupt OAuth
signup.
- Leaves existing email signup and SAML behavior unchanged.

## How to test

- Start from a Chatwoot Cloud-like setup with attribution cookies
present.
- Sign up using the Google OAuth button.
- Confirm the created account has
`internal_attributes['marketing_attribution']` with `first_touch` and
`last_touch`.
- Confirm existing Google OAuth login still redirects normally.

Validation run locally:

- `bundle exec rspec
spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb`
- `bundle exec rspec
spec/controllers/devise/omniauth_callbacks_controller_spec.rb
spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb`
- `bundle exec rubocop
enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb
spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb`
2026-06-22 06:51:09 -07:00
2b37fef0e0 fix(companies): sync contact company names (#14759)
Fixes company-contact name drift when a company is renamed or deleted.

Closes: N/A

## Why
Contacts keep a denormalized `additional_attributes.company_name` for
display and filtering. Company rename/delete flows could leave that
copied value stale even though the actual `company_id` relationship
changed.

## What changed
- Enqueues an async company contact-name sync job when a company name
changes.
- Moves company deletion into `Companies::DeleteJob`.
- The delete job unlinks linked contacts, clears only the copied
`company_name`, and then deletes the company.
- Uses bulk JSON updates for the cleanup path so contact records are not
saved, which avoids contact update callbacks, webhook dispatch, and
automation side effects.

## How to test
- Link a contact to a company, rename the company, and confirm the
contact company name updates after the job runs.
- Delete a company with linked contacts and confirm the delete job
removes the company, unassigns linked contacts, and preserves other
contact additional attributes.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-22 06:49:52 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony Mathew
44b32eacec chore(deps): bump faraday from 2.14.2 to 2.14.3 (#14811)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.14.2 to
2.14.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/lostisland/faraday/releases">faraday's
releases</a>.</em></p>
<blockquote>
<h2>v2.14.3</h2>
<h2>Security Note</h2>
<p>This release contains a security fix, we recommend all users to
upgrade as soon as possible.
A Security Advisory with more details will be posted shortly.</p>
<h2>What's Changed</h2>
<ul>
<li>Upgrade CI lint step from Ruby 3 to 4 by <a
href="https://github.com/larouxn"><code>@​larouxn</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1673">lostisland/faraday#1673</a></li>
<li>feat(test): add Stubs#clear to remove all stubs by <a
href="https://github.com/SAY-5"><code>@​SAY-5</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1675">lostisland/faraday#1675</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/SAY-5"><code>@​SAY-5</code></a> made
their first contribution in <a
href="https://redirect.github.com/lostisland/faraday/pull/1675">lostisland/faraday#1675</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lostisland/faraday/compare/v2.14.2...v2.14.3">https://github.com/lostisland/faraday/compare/v2.14.2...v2.14.3</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/lostisland/faraday/commit/f1ace878bae16c1df298b6af7c34657f2c4dcddb"><code>f1ace87</code></a>
Version bump to 2.14.3</li>
<li><a
href="https://github.com/lostisland/faraday/commit/36764bfc48aa9fc08336d36600ea67af149c80fa"><code>36764bf</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/lostisland/faraday/commit/59334e0e9b1918bd26b143f4a7bbc8c765de3ac4"><code>59334e0</code></a>
feat(test): add Stubs#clear to remove all stubs (<a
href="https://redirect.github.com/lostisland/faraday/issues/1675">#1675</a>)</li>
<li><a
href="https://github.com/lostisland/faraday/commit/469f25c793f25bcfdd416a54edeb454d045c08d6"><code>469f25c</code></a>
Upgrade CI lint step from Ruby 3 to 4 (<a
href="https://redirect.github.com/lostisland/faraday/issues/1673">#1673</a>)</li>
<li>See full diff in <a
href="https://github.com/lostisland/faraday/compare/v2.14.2...v2.14.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=faraday&package-manager=bundler&previous-version=2.14.2&new-version=2.14.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

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

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

---

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

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

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-22 17:36:08 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony Mathew
5b7430f376 chore(deps): bump concurrent-ruby from 1.3.5 to 1.3.7 (#14810)
Bumps
[concurrent-ruby](https://github.com/ruby-concurrency/concurrent-ruby)
from 1.3.5 to 1.3.7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ruby-concurrency/concurrent-ruby/releases">concurrent-ruby's
releases</a>.</em></p>
<blockquote>
<h2>v1.3.7</h2>
<!-- raw HTML omitted -->
<p>There are 3 security fixes in this release, so updating is
recommended.
These security vulnerabilities are not very likely to be hit in practice
and have a corresponding <code>Low</code> severity score.</p>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<ul>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/security/advisories/GHSA-h8w8-99g7-qmvj">CVE-2026-54904</a>
<code>AtomicReference#update</code> livelocks when the stored value is
<code>Float::NAN</code>. Fix by <a
href="https://github.com/joshuay03"><code>@​joshuay03</code></a> and <a
href="https://github.com/eregon"><code>@​eregon</code></a></li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/security/advisories/GHSA-wv3x-4vxv-whpp">CVE-2026-54905</a>
<code>ReentrantReadWriteLock</code> read-count overflow grants a write
lock without exclusivity. Fix by <a
href="https://github.com/joshuay03"><code>@​joshuay03</code></a></li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/security/advisories/GHSA-6wx8-w4f5-wwcr">CVE-2026-54906</a>
<code>ReadWriteLock</code> allows wrong-thread write release and stray
read-release counter corruption. Fix by <a
href="https://github.com/joshuay03"><code>@​joshuay03</code></a></li>
<li>concurrent-ruby-ext: fix build on Darwin 32-bit by <a
href="https://github.com/barracuda156"><code>@​barracuda156</code></a>
in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1064">ruby-concurrency/concurrent-ruby#1064</a></li>
<li>Add SECURITY.md by <a
href="https://github.com/eregon"><code>@​eregon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1104">ruby-concurrency/concurrent-ruby#1104</a></li>
<li>Add Ruby 4.0 in CI by <a
href="https://github.com/eregon"><code>@​eregon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1106">ruby-concurrency/concurrent-ruby#1106</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/barracuda156"><code>@​barracuda156</code></a>
made their first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1064">ruby-concurrency/concurrent-ruby#1064</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ruby-concurrency/concurrent-ruby/compare/v1.3.6...v1.3.7">https://github.com/ruby-concurrency/concurrent-ruby/compare/v1.3.6...v1.3.7</a></p>
<h2>v1.3.6</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<ul>
<li>Run tests without the C extension in CI by <a
href="https://github.com/eregon"><code>@​eregon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1081">ruby-concurrency/concurrent-ruby#1081</a></li>
<li>Fix typo in Promise docs by <a
href="https://github.com/danieldiekmeier"><code>@​danieldiekmeier</code></a>
in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1083">ruby-concurrency/concurrent-ruby#1083</a></li>
<li>Correct word in readme by <a
href="https://github.com/wwahammy"><code>@​wwahammy</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1084">ruby-concurrency/concurrent-ruby#1084</a></li>
<li>Fix mistakes in MVar documentation by <a
href="https://github.com/trinistr"><code>@​trinistr</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1087">ruby-concurrency/concurrent-ruby#1087</a></li>
<li>Fix multi require concurrent/executor/cached_thread_pool by <a
href="https://github.com/OuYangJinTing"><code>@​OuYangJinTing</code></a>
in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1085">ruby-concurrency/concurrent-ruby#1085</a></li>
<li>Use typed data APIs by <a
href="https://github.com/nobu"><code>@​nobu</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1096">ruby-concurrency/concurrent-ruby#1096</a></li>
<li>Add Joshua Young to the list of maintainers by <a
href="https://github.com/eregon"><code>@​eregon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1097">ruby-concurrency/concurrent-ruby#1097</a></li>
<li>Asynchronous pruning for RubyThreadPoolExecutor by <a
href="https://github.com/joshuay03"><code>@​joshuay03</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1082">ruby-concurrency/concurrent-ruby#1082</a></li>
<li>Mark RubySingleThreadExecutor as a SerialExecutorService by <a
href="https://github.com/meineerde"><code>@​meineerde</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1070">ruby-concurrency/concurrent-ruby#1070</a></li>
<li>Allow TimerTask to be safely restarted after shutdown and avoid
duplicate tasks by <a
href="https://github.com/bensheldon"><code>@​bensheldon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1001">ruby-concurrency/concurrent-ruby#1001</a></li>
<li>Flaky test fix: allow ThreadPool to shutdown before asserting
completed_task_count by <a
href="https://github.com/bensheldon"><code>@​bensheldon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1098">ruby-concurrency/concurrent-ruby#1098</a></li>
<li><code>ThreadPoolExecutor#kill</code> will
<code>wait_for_termination</code> in JRuby; ensure <code>TimerSet</code>
timer thread shuts down cleanly by <a
href="https://github.com/bensheldon"><code>@​bensheldon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1044">ruby-concurrency/concurrent-ruby#1044</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/danieldiekmeier"><code>@​danieldiekmeier</code></a>
made their first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1083">ruby-concurrency/concurrent-ruby#1083</a></li>
<li><a href="https://github.com/wwahammy"><code>@​wwahammy</code></a>
made their first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1084">ruby-concurrency/concurrent-ruby#1084</a></li>
<li><a href="https://github.com/trinistr"><code>@​trinistr</code></a>
made their first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1087">ruby-concurrency/concurrent-ruby#1087</a></li>
<li><a
href="https://github.com/OuYangJinTing"><code>@​OuYangJinTing</code></a>
made their first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1085">ruby-concurrency/concurrent-ruby#1085</a></li>
<li><a href="https://github.com/nobu"><code>@​nobu</code></a> made their
first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1096">ruby-concurrency/concurrent-ruby#1096</a></li>
<li><a href="https://github.com/joshuay03"><code>@​joshuay03</code></a>
made their first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1082">ruby-concurrency/concurrent-ruby#1082</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ruby-concurrency/concurrent-ruby/compare/v1.3.5...v1.3.6">https://github.com/ruby-concurrency/concurrent-ruby/compare/v1.3.5...v1.3.6</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ruby-concurrency/concurrent-ruby/blob/master/CHANGELOG.md">concurrent-ruby's
changelog</a>.</em></p>
<blockquote>
<h2>Release v1.3.7 (16 June 2026)</h2>
<p>concurrent-ruby:</p>
<ul>
<li>See the <a
href="https://github.com/ruby-concurrency/concurrent-ruby/releases/tag/v1.3.7">release
notes on GitHub</a>.</li>
</ul>
<h2>Release v1.3.6 (13 December 2025)</h2>
<p>concurrent-ruby:</p>
<ul>
<li>See the <a
href="https://github.com/ruby-concurrency/concurrent-ruby/releases/tag/v1.3.6">release
notes on GitHub</a>.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/4c8fc28ab6bb9bd8258a4c0c2fa6d35ebe77b3cb"><code>4c8fc28</code></a>
Release 1.3.7</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/d91ca9426cb819d6cc63f1bd64bfe54644d0beca"><code>d91ca94</code></a>
Fix AtomicReference#update livelock when stored value is Float::NAN on
JRuby ...</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/7e4d711bacf7a1dac3ef6bda44004387be2dc7e6"><code>7e4d711</code></a>
Fix <code>ReentrantReadWriteLock</code> read hold overflow into
write-lock bit</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/6e37e0644b83b182971dc540d2e4bee38df61386"><code>6e37e06</code></a>
Fix <code>AtomicReference#update</code> livelock when stored value is
<code>Float::NAN</code></li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/2825cfa12cb708b76557803957f76862eb1151a2"><code>2825cfa</code></a>
Cleanup spec</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/3fd493283ca5f84f0ef4e84aabd43ad68df4626b"><code>3fd4932</code></a>
Fix <code>ReadWriteLock</code> wrong-thread write release and stray read
release</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/1974b4772efc034ee8eaa562b4370343f4c5c54b"><code>1974b47</code></a>
Add Ruby 4.0 in CI</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/df8706d40c483d76bbb0b3a35a633c68fa9e17be"><code>df8706d</code></a>
Add SECURITY.md (<a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/issues/1104">#1104</a>)</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/7a1b78941c081106c20a9ca0144ac73a48d254ab"><code>7a1b789</code></a>
Bump actions/upload-pages-artifact from 4 to 5</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/9b2dbf712896a638a73d2fa221206961c8d6484d"><code>9b2dbf7</code></a>
Bump actions/deploy-pages from 4 to 5</li>
<li>Additional commits viewable in <a
href="https://github.com/ruby-concurrency/concurrent-ruby/compare/v1.3.5...v1.3.7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=concurrent-ruby&package-manager=bundler&previous-version=1.3.5&new-version=1.3.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

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

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

---

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

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

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-22 16:08:06 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony Mathew
5c00a1601a chore(deps): bump dompurify from 3.4.0 to 3.4.11 (#14798)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.0 to
3.4.11.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/cure53/DOMPurify/releases">dompurify's
releases</a>.</em></p>
<blockquote>
<h2>DOMPurify 3.4.11</h2>
<ul>
<li>Fixed an issue with a leaky config for hooks via
<code>setConfig</code>, thanks <a
href="https://github.com/trace37labs"><code>@​trace37labs</code></a></li>
<li>Bumped vulnerable development dependencies to arrive at plain 0 with
<code>npm audit</code></li>
<li>Updated the <code>osv-scanner</code> suppression list as no
vulnerable dependencies are left for now</li>
<li>Updated up the linting tool-chain and removed now-redundant lint
directives</li>
<li>Updated the documentation is several spots, README, wiki, etc.</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.10</h2>
<ul>
<li>Refactored codebase for clarity: extracted the public type
declarations into <code>types.ts</code></li>
<li>Decomposed the three largest sanitizer functions into focused
helpers</li>
<li>Removed duplicated defaults and dead branches, consolidated
<code>SAFE_FOR_TEMPLATES</code> scrubbing into single shared path</li>
<li>Improved per-node performance by hoisting the mXSS probe regexes and
testing <code>textContent</code> before <code>innerHTML</code></li>
<li>Added a deterministic micro-benchmark harness (<code>npm run
bench</code>) with a <code>--compare</code> mode</li>
<li>Reduced CI cost by running the full three-engine browser suite once
per PR</li>
<li>Refreshed the <code>demos/</code> folder so every demo runs again,
and added a SVG-via-<code>&lt;img&gt;</code> demo</li>
<li>Documented the bench and <code>test:happydom</code> scripts in the
README</li>
<li>Completed the Attack Classes &amp; Bypass History wiki page</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.9</h2>
<ul>
<li>Further improved the handling of Trusted Types config options,
thanks <a
href="https://github.com/offset"><code>@​offset</code></a></li>
<li>Further improved the handling of <code>IN_PLACE</code> sanitization,
thanks <a
href="https://github.com/mozfreddyb"><code>@​mozfreddyb</code></a></li>
<li>Added more test coverage for <code>IN_PLACE</code> and Trusted Types
related usage</li>
<li>Bumped several dependencies where possible</li>
<li>Updated README and wiki with more accurate documentation &amp;
attack samples</li>
</ul>
<h2>DOMPurify 3.4.8</h2>
<ul>
<li>Cleaned up the repository root, renamed some and removed unneeded
files</li>
<li>Fixed an issue with handling of Trusted Types policies, thanks <a
href="https://github.com/fulstadev"><code>@​fulstadev</code></a></li>
<li>Fixed the node iterator for better template scrubbing, thanks <a
href="https://github.com/IamLeandrooooo"><code>@​IamLeandrooooo</code></a></li>
<li>Included formerly missing LICENSE-MPL in published npm package,
thanks <a
href="https://github.com/asamuzaK"><code>@​asamuzaK</code></a></li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.7</h2>
<ul>
<li>Hardened the handling of Shadow Roots when using
<code>IN_PLACE</code>, thanks <a
href="https://github.com/GameZoneHacker"><code>@​GameZoneHacker</code></a></li>
<li>Removed a problem leading to permanent hook pollution, thanks <a
href="https://github.com/offset"><code>@​offset</code></a></li>
<li>Refactored the test suite and expanded test coverage
significantly</li>
</ul>
<h2>DOMPurify 3.4.6</h2>
<ul>
<li>Fixed several issues with DOM Clobbering in <code>IN_PLACE</code>
mode, thanks <a
href="https://github.com/offset"><code>@​offset</code></a> &amp; <a
href="https://github.com/Bankde"><code>@​Bankde</code></a></li>
<li>Hardened the checks for cross-realm <code>IN_PLACE</code> and Shadow
DOM sanitization, thanks <a
href="https://github.com/offset"><code>@​offset</code></a> &amp; <a
href="https://github.com/Bankde"><code>@​Bankde</code></a></li>
<li>Added more test coverage for <code>IN_PLACE</code> and general DOM
Clobbering attacks</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.5</h2>
<ul>
<li>Fixed a bypass caused by the new HTML element
<code>selectedcontent</code> added in 3.4.4, thanks <a
href="https://github.com/KabirAcharya"><code>@​KabirAcharya</code></a></li>
</ul>
<p><strong>Note that this is a security release for an issue introduced
in 3.4.4 and should be upgraded to immediately.</strong></p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/cure53/DOMPurify/commit/0cae5187403132f96a6d357649e4b15633fc210a"><code>0cae518</code></a>
release: 3.4.11 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1494">#1494</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/6ee5716f8336989753611beeca364957c0eb0c3e"><code>6ee5716</code></a>
release: 3.4.10 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1478">#1478</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/52102472d46035857c52df19e44285f8a1e102fc"><code>5210247</code></a>
release: 3.4.9 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1459">#1459</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/bcdd8285412dc9c4c149652aed2d712e790d6ccf"><code>bcdd828</code></a>
release: 3.4.8 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1439">#1439</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/ca30f070c360df162a3e3848e80e6fd3c9e74bff"><code>ca30f07</code></a>
release: 3.4.7 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1414">#1414</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/bb7739e5bccec7e1ab3dae3f3e42d02db3acaaae"><code>bb7739e</code></a>
release: 3.4.6 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1394">#1394</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/011b0c78f2a0f57ee54f5fcccb697a46ca6e63ea"><code>011b0c7</code></a>
release: 3.4.5 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1382">#1382</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/5817ad969c15e67dfcd6cb37248d6e9c1553e7c3"><code>5817ad9</code></a>
release: 3.4.4 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1374">#1374</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/520edb0371a9638f9b51f1798051299a250c686b"><code>520edb0</code></a>
release: 3.4.3 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1352">#1352</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/6f67fd396a7b8c64294343999fe607ca1f5299c0"><code>6f67fd3</code></a>
Sync/3.4.2 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1322">#1322</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/cure53/DOMPurify/compare/3.4.0...3.4.11">compare
view</a></li>
</ul>
</details>
<details>
<summary>Install script changes</summary>
<p>This version adds <code>prepare</code> script that runs during
installation. Review the package contents before updating.</p>
</details>
<br />


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

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

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

---

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

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

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-22 14:58:07 +05:30
a4be38f8f3 fix(facebook): handle messenger sticker attachment type (#14793)
Facebook Messenger sticker messages no longer break message ingestion.
Meta recently changed its webhook payloads so a sticker now arrives as a
new `sticker` attachment type (alongside the existing `image` attachment
during the transition period). Chatwoot didn't recognise `sticker` as a
valid attachment file type, so the webhook job crashed and the message —
and any others in the same batch — failed to sync. Stickers now appear
in the conversation as a single image, just like before.

Fixes https://linear.app/chatwoot/issue/PLA-177

## How to reproduce

1. Connect a Facebook Page inbox.
2. Send a sticker from Messenger to that page.
3. Before this change: the `Webhooks::FacebookEventsJob` raises
`ArgumentError: 'sticker' is not a valid file_type` and the message is
dropped.
4. After this change: the sticker shows up in the conversation as a
single image attachment.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:40:23 +04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony Mathew
6fcc888aee chore(deps): bump oj from 3.16.10 to 3.17.3 (#14809)
Bumps [oj](https://github.com/ohler55/oj) from 3.16.10 to 3.17.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ohler55/oj/releases">oj's
releases</a>.</em></p>
<blockquote>
<h2>v3.17.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix stack limits by <a
href="https://github.com/ohler55"><code>@​ohler55</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1014">ohler55/oj#1014</a></li>
<li>Fix intern.c and fast.c by <a
href="https://github.com/ohler55"><code>@​ohler55</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1015">ohler55/oj#1015</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ohler55/oj/compare/v3.17.1...v3.17.3">https://github.com/ohler55/oj/compare/v3.17.1...v3.17.3</a></p>
<h2>v3.17.1</h2>
<h2>What's Changed</h2>
<ul>
<li>Add <code>max_integer_digits</code> limit for legacy integer parsing
by <a href="https://github.com/meinac"><code>@​meinac</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1009">ohler55/oj#1009</a></li>
<li>fix: remove unsafe exec() in fast.c by <a
href="https://github.com/orbisai0security"><code>@​orbisai0security</code></a>
in <a
href="https://redirect.github.com/ohler55/oj/pull/1011">ohler55/oj#1011</a></li>
<li>Revert &quot;fix: remove unsafe exec() in fast.c&quot; by <a
href="https://github.com/ohler55"><code>@​ohler55</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1012">ohler55/oj#1012</a></li>
<li>Fix reentrant parser by <a
href="https://github.com/ohler55"><code>@​ohler55</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1013">ohler55/oj#1013</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/orbisai0security"><code>@​orbisai0security</code></a>
made their first contribution in <a
href="https://redirect.github.com/ohler55/oj/pull/1011">ohler55/oj#1011</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ohler55/oj/compare/v3.17.0...v3.17.1">https://github.com/ohler55/oj/compare/v3.17.0...v3.17.1</a></p>
<h2>v3.17.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Implement Oj::Parser.safe with configurable JSON safety limits by <a
href="https://github.com/meinac"><code>@​meinac</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1007">ohler55/oj#1007</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ohler55/oj/compare/v3.16.17...v3.17.0">https://github.com/ohler55/oj/compare/v3.16.17...v3.17.0</a></p>
<h2>v3.16.17</h2>
<h2>What's Changed</h2>
<ul>
<li>Use fast_memcpy16 to quickly copy up to 16 bytes for tail handling
in ARM Neon code and small copies when generating JSON. by <a
href="https://github.com/samyron"><code>@​samyron</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/998">ohler55/oj#998</a></li>
<li>Bump jidicula/clang-format-action from 4.15.0 to 4.17.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/ohler55/oj/pull/999">ohler55/oj#999</a></li>
<li>Array hash as json by <a
href="https://github.com/ohler55"><code>@​ohler55</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1003">ohler55/oj#1003</a></li>
<li>Bump jidicula/clang-format-action from 4.17.0 to 4.18.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/ohler55/oj/pull/1001">ohler55/oj#1001</a></li>
<li>Handle unterminated strings in usual parser by <a
href="https://github.com/meinac"><code>@​meinac</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1002">ohler55/oj#1002</a></li>
<li>Fix read() not handling partial reads for large files by <a
href="https://github.com/ursm"><code>@​ursm</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1004">ohler55/oj#1004</a></li>
<li>Raise error for incomplete primitive literals by <a
href="https://github.com/meinac"><code>@​meinac</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1005">ohler55/oj#1005</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/ursm"><code>@​ursm</code></a> made their
first contribution in <a
href="https://redirect.github.com/ohler55/oj/pull/1004">ohler55/oj#1004</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ohler55/oj/compare/v3.16.16...v3.16.17">https://github.com/ohler55/oj/compare/v3.16.16...v3.16.17</a></p>
<h2>v3.16.14</h2>
<h2>What's Changed</h2>
<ul>
<li>Change first arg to oj_parse_options by <a
href="https://github.com/ohler55"><code>@​ohler55</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/987">ohler55/oj#987</a></li>
<li>Fix illegal instruction error on CPUs without SSE4.2 support by <a
href="https://github.com/sebyx07"><code>@​sebyx07</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/993">ohler55/oj#993</a></li>
<li>Optimize <code>oj_dump_cstr</code> using SSE4.2 and SSSE3. by <a
href="https://github.com/samyron"><code>@​samyron</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/973">ohler55/oj#973</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ohler55/oj/compare/v3.16.13...v3.16.14">https://github.com/ohler55/oj/compare/v3.16.13...v3.16.14</a></p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ohler55/oj/blob/develop/CHANGELOG.md">oj's
changelog</a>.</em></p>
<blockquote>
<h2>3.17.3 - 2026-06-04</h2>
<ul>
<li>Fixed issue in intern.c and fast.c.</li>
</ul>
<h2>3.17.2 - 2026-05-27</h2>
<ul>
<li>Fixed multiple issues related to extreme sizes.</li>
</ul>
<h2>3.17.1 - 2026-05-15</h2>
<ul>
<li>Fixed &quot;quoted string not terminated&quot; error.</li>
</ul>
<h2>3.17.0 - 2026-04-19</h2>
<ul>
<li>A &quot;safe&quot; parser has been added as a variation of the
Oj:Parser thanks to <a
href="https://github.com/meinac"><code>@​meinac</code></a>.</li>
</ul>
<h2>3.16.17 - 2026-04-12</h2>
<ul>
<li>
<p>Rails optimize for Hash and Array now overrides <code>as_json</code>
for those
classes. Note that when either is optimized with
<code>Oj.optimize_rails</code>
the <code>Array.as_json</code> and <code>Hash.as_json</code> will not be
called.</p>
</li>
<li>
<p>Add support for the rails encoder <code>:only</code> and
<code>:except</code> options.</p>
</li>
<li>
<p>Handle unterminated strings in usual parser (<a
href="https://redirect.github.com/ohler55/oj/issues/1002">#1002</a>)</p>
</li>
<li>
<p>Fix read() not handling partial reads for large files (<a
href="https://redirect.github.com/ohler55/oj/issues/1004">#1004</a>)</p>
</li>
<li>
<p>Raise error for incomplete primitive literals (<a
href="https://redirect.github.com/ohler55/oj/issues/1005">#1005</a>)</p>
</li>
</ul>
<h2>3.16.16 - 2026-03-13</h2>
<ul>
<li>Not closed arrays and objects are reported corrected in the usual
parser.</li>
</ul>
<h2>3.16.15 - 2026-02-05</h2>
<ul>
<li>Fixed by putting the ostruct dependency back until a better way is
found to conditionally include it.</li>
</ul>
<h2>3.16.14 - 2026-02-04</h2>
<ul>
<li>
<p>Fixed SSE issue <a
href="https://redirect.github.com/ohler55/oj/issues/989">#989</a>.</p>
</li>
<li>
<p>Removed ostruct dependency.</p>
</li>
<li>
<p>Removed generic object JSON gem tests.</p>
</li>
</ul>
<h2>3.16.13 - 2025-12-05</h2>
<ul>
<li>Fixed rails encoding for Hash and Array subclasses.</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ohler55/oj/commit/bbde91a679728f94c4492ebc3683f4fa3309049f"><code>bbde91a</code></a>
Fix intern.c and fast.c (<a
href="https://redirect.github.com/ohler55/oj/issues/1015">#1015</a>)</li>
<li><a
href="https://github.com/ohler55/oj/commit/495cc38fc5a02681da2175960d4a667fae48f3c9"><code>495cc38</code></a>
Update changelog</li>
<li><a
href="https://github.com/ohler55/oj/commit/810a57acc7aaacef8410b460559c08762d0eb61a"><code>810a57a</code></a>
Update changelog</li>
<li><a
href="https://github.com/ohler55/oj/commit/ec368dbe936ef0104b782e4b0f67b17d6c7276f7"><code>ec368db</code></a>
Fix stack limits (<a
href="https://redirect.github.com/ohler55/oj/issues/1014">#1014</a>)</li>
<li><a
href="https://github.com/ohler55/oj/commit/4587e87e23adc9a4163834dc8c9ba9d7206c6501"><code>4587e87</code></a>
Fix reentrant parser (<a
href="https://redirect.github.com/ohler55/oj/issues/1013">#1013</a>)</li>
<li><a
href="https://github.com/ohler55/oj/commit/ea0c3b8fdc006807dfd768606548f6f15639b7dc"><code>ea0c3b8</code></a>
Fix compile warnings</li>
<li><a
href="https://github.com/ohler55/oj/commit/45d13099a64c37e231f6cd32bb126ce0270d81c4"><code>45d1309</code></a>
Revert &quot;fix: V-001 security vulnerability (<a
href="https://redirect.github.com/ohler55/oj/issues/1011">#1011</a>)&quot;
(<a
href="https://redirect.github.com/ohler55/oj/issues/1012">#1012</a>)</li>
<li><a
href="https://github.com/ohler55/oj/commit/93ce414fc0b9b1ad2a19311101b6a443ec666320"><code>93ce414</code></a>
fix: V-001 security vulnerability (<a
href="https://redirect.github.com/ohler55/oj/issues/1011">#1011</a>)</li>
<li><a
href="https://github.com/ohler55/oj/commit/d68a31ac5bcaaeeed22fb32aeff35aef467ab8d2"><code>d68a31a</code></a>
Add <code>max_integer_digits</code> limit for legacy integer parsing (<a
href="https://redirect.github.com/ohler55/oj/issues/1009">#1009</a>)</li>
<li><a
href="https://github.com/ohler55/oj/commit/babd7a11e78a221f6434c5e9e8b9ede9947243e3"><code>babd7a1</code></a>
Remove out of date performance comparisons</li>
<li>Additional commits viewable in <a
href="https://github.com/ohler55/oj/compare/v3.16.10...v3.17.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=oj&package-manager=bundler&previous-version=3.16.10&new-version=3.17.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

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

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

---

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

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

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-22 14:09:28 +05:30
Aakash BakhleandGitHub c27a5916bb fix: auto enable document auto-sync (#14806)
# Pull Request Template

## Description

Auto enables document auto-sync on paid plans

fixes:
https://linear.app/chatwoot/issue/AI-186/captain-auto-sync-doesnt-turn-on-automatically-on-subscription

## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-22 13:39:43 +05:30
Sony MathewandGitHub 66609f06fd fix: Bump Nokogiri to 1.19.4 (#14807)
# Pull Request Template

## Description

This updates Nokogiri from `1.19.3` to `1.19.4` so the bundle-audit lint
step stops flagging the newly published Nokogiri advisories. Chatwoot's
direct Nokogiri usage appears limited to ordinary HTML/XML parsing and
selector traversal, but the locked dependency is below the patched
floor, so the safe remediation is the patch-level upgrade rather than an
advisory override.

Fixes
[CW-7397](https://linear.app/chatwoot/issue/CW-7397/upgrade-nokogiri-to-1194-for-bundle-audit-advisories)

## Type of change

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

## How Has This Been Tested?

Reference failed build in CI because of bundle audit:
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114392/workflows/8a252bf9-5e58-45fd-af18-a32dbebe978b/jobs/160423

- `bundle exec bundle audit update && bundle exec bundle audit check -v`
passed with no vulnerabilities found.
- `bundle exec rspec spec/services/website_branding_service_spec.rb
spec/presenters/html_parser_spec.rb
spec/enterprise/services/enterprise/website_branding_service_spec.rb
spec/enterprise/services/captain/tools/simple_page_crawl_service_spec.rb`
passed with 27 examples and 0 failures.
- `bundle exec rubocop app/services/website_branding_service.rb
app/presenters/html_parser.rb
enterprise/app/services/page_crawler_service.rb
enterprise/app/services/captain/tools/html_page_parser.rb
enterprise/app/services/captain/tools/simple_page_crawl_service.rb`
passed with no offenses.
- `git diff --check` passed.

Note: broad `bundle exec rubocop --parallel` still reports existing
generated DB/schema offenses unrelated to this lockfile-only dependency
bump.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-22 12:07:58 +04:00
6a262287d2 feat(captain): allow agents to report Captain messages (#14799)
Adds a cloud-only flow for agents to flag incorrect or problematic
Captain (AI) responses. Right-clicking a Captain message surfaces a
"Report message" option that opens a dialog to pick a problem type and
add a description, persisted to a new captain_message_reports table for
the team to review.


<img width="636" height="542" alt="Screenshot 2026-06-20 at 9 15 56 AM"
src="https://github.com/user-attachments/assets/afaa233d-6bd6-455a-8a33-a3796a3e3ef6"
/>
<img width="580" height="502" alt="Screenshot 2026-06-20 at 9 16 03 AM"
src="https://github.com/user-attachments/assets/2d220d99-98cc-4c5e-a325-778ceb4f7bc9"
/>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:47:08 -07:00
Sojan JoseandGitHub dfd656a7d9 feat(account): persist signup attribution (#14761)
This keeps Chatwoot-side attribution persistence intentionally small and
Enterprise-only.

The website owns attribution capture, normalization, and source
classification. Chatwoot Cloud only reads the already-shaped first-party
attribution cookies during the current web signup account creation path
and stores the decoded payload in internal account metadata. Self-hosted
installs remain unchanged in this repo.

## What changed

- Added Enterprise-only attribution persistence to the current Cloud web
signup account creation path.
- Stores attribution only when `ChatwootApp.chatwoot_cloud?` is true.
- Reads the existing first-touch and last-touch attribution cookies.
- Saves only the documented scalar attribution fields under account
internal metadata.
- Preserves raw attribution values and leaves escaping to display
boundaries.
- Bounds stored attribution values to the website field-size limit.
- Keeps OSS controller code unchanged.
- Keeps signup attribution request coverage in Enterprise specs.
- Avoids backend attribution derivation, referrer parsing, or fallback
classification.
- Skips authenticated add-workspace flows so additional workspaces are
not counted as signup attribution.
- Does not hook unused account creation paths or OmniAuth account
creation.

## How to test

- `bundle exec rubocop
spec/controllers/api/v1/accounts_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts_controller_spec.rb
enterprise/app/controllers/enterprise/api/v1/accounts_settings.rb
enterprise/app/services/internal/accounts/marketing_attribution_service.rb
spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb`
- `bundle exec rspec spec/controllers/api/v1/accounts_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts_controller_spec.rb
spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb`
- On a cloud-like setup, create an account through the current web
signup path with attribution cookies and confirm account internal
metadata is populated.
- On a non-cloud setup or authenticated add-workspace flow, confirm
account-create behavior is unchanged and no attribution is stored.
2026-06-19 11:01:12 -07:00
Vishnu NarayananandGitHub b02e732dd1 fix: throttle DELETE conversation API per account (CW-7356) (#14747)
## Description

A single account deleted ~167,000 conversations via the API in 60
minutes (avg 46 rps, peak 151 rps/min) on 15 Jun 2026 21:00 to 22:00
IST. The cascade through `dependent: :destroy_async` on conversations,
then messages, then reporting_events, created enough job queue and DB
pressure to drive RDS CPU to ~93%, triggering 5xx alerts and
connection-pool timeouts.

This adds a `Rack::Attack` per-account throttle on `DELETE
/api/v1/accounts/:id/conversations/:id`:

- Default 60 req/min per account; configurable via the
`RATE_LIMIT_CONVERSATION_DELETE` env var.


Fixes [CW-7356](https://linear.app/chatwoot/issue/CW-7356)
2026-06-19 15:11:39 +05:30
80b132e155 feat(tiktok): add cloud warning (#14768)
Adds Cloud-only warning on the TikTok connect page while North American
account connections may be temporarily unavailable.

Fixes
https://linear.app/chatwoot/issue/CW-7322/cloud-show-tiktok-north-america-warning-on-connect-page

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-06-19 12:29:07 +04:00
Sivin VargheseandGitHub 32b63ad0c0 feat: add search to Help Center admin pages (#14772) 2026-06-19 12:41:21 +05:30
Sivin VargheseandGitHub ac15339456 fix: Resolve Firefox input issues and persist advanced filters (#14781)
# Pull Request Template

## Description

This PR fixes a few issues in Global Search and removes a non-functional
control.

* Fixes an issue in Firefox where characters could be dropped while
typing in the search input. The search now uses the latest input value
directly, preventing searches from running one character behind.

* Removes a stale query sync in `SearchHeader` that could overwrite
recently typed characters during the debounce window, causing the input
to appear out of sync.

* Fixes advanced search filters being removed from the URL on page
reload. The search page now waits for account data to load before
parsing URL parameters, ensuring agent, inbox, and date range filters
are preserved.

* Removes the non-functional "Sort by relevance" button from the search
tabs bar, as it was disabled and had no effect.


Fixes https://github.com/chatwoot/chatwoot/issues/14684
[CW-7305](https://linear.app/chatwoot/issue/CW-7305/global-search-drops-characters-while-typing-in-firefox-query-truncated)
[CW-7370](https://linear.app/chatwoot/issue/CW-7370/remove-non-functional-relevance-placeholder-button-from-global-search)

## Type of change

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

## How Has This Been Tested?

### Screencast

**Before**


https://github.com/user-attachments/assets/48d72a4e-20f4-4f24-91f4-2c9a9c065eba




**After**


https://github.com/user-attachments/assets/0e41a803-b4fd-410d-a739-549f72d418d6





## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-19 10:46:47 +04:00
Sony Mathew f99187646e Merge branch 'release/4.15.1' into develop 2026-06-17 18:07:48 +05:30
Sony Mathew 5ce6e00730 Bump version to 4.15.1 2026-06-17 18:06:42 +05:30
Vishnu NarayananandGitHub f66b551c7d revert: Sidebar unread counts for filters (CW-7262) (#14769)
## Description

Reverts [#14726](https://github.com/chatwoot/chatwoot/pull/14726)
(\"feat: Add sidebar unread counts for filters (CW-7262)\"), which
shipped in 4.15.0.

After 4.15.0 rolled out to prod the unread-counts-for-filters code path
caused a cascading incident:

- `Counter#ensure_filters_cache!` fires on every `/unread_counts/index`
and `update_last_seen` request.
- On cache miss it calls `Builder#build_filters_for!`, which:
- invokes `store.clear_user_filters!` -> `delete_matching` -> a Redis
`SCAN_each` over a per-user pattern keyspace, and
- runs 4 fresh SQL passes per user (mentions, participating, unattended,
and per-folder `Conversations::FilterService` queries).
- Threads blocked in the SCAN held their DB connections, the connection
pool exhausted, Sidekiq jobs were discarded with
`ActiveJob::DeserializationError: could not obtain a connection from the
pool`, and the enqueued queue blew past 200K.


Related:
[CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders)

## Type of change

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

## How Has This Been Tested?


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-17 14:58:48 +04:00
Sony Mathew c12e1f8348 Merge branch 'release/4.15.0' into develop 2026-06-17 00:11:59 +05:30
Sony Mathew 124782938d Bump version to 4.15.0 2026-06-17 00:10:47 +05:30
Vishnu NarayananandGitHub c70a57407d fix: label Chatwoot Mobile sessions instead of 'Unknown Device' (#14753)
## Description

Sessions created by logins from the Chatwoot Mobile app currently render
as "Unknown Device" in the dashboard sessions UI. The mobile app's HTTP
layer sends:

- Android: `User-Agent: okhttp/4.9.2`
- iOS: `User-Agent: Chatwoot/<build> CFNetwork/<v> Darwin/<v>`

Neither pattern is classifiable by the `browser` gem, so
\`browser_name\`, \`platform_name\`, and \`device_name\` all end up
"Unknown".

This change adds a backend-only fallback in
\`UserSessionTrackingService\`. When \`Browser.new(ua)\` returns
"Unknown Browser" and the UA matches a known Chatwoot Mobile pattern,
the labels are overridden to \`Chatwoot Mobile\` + \`Android\` /
\`iPhone\`. The Vue sessions list (\`ActiveSessions.vue\`) then renders
"Chatwoot Mobile on Android" or "Chatwoot Mobile on iPhone" with the
smartphone icon.

This is the immediate floor. A follow-up will add structured
\`X-Chatwoot-*\` headers from the mobile app so we can render full
version + device model.

Fixes INF-75.

## Type of change

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

## How Has This Been Tested?

- Added specs

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-17 00:05:29 +05:30
fe6368b42e feat: Add sidebar unread counts for filters (CW-7262) (#14726)
## Description

Extends the conversation unread-count system so the left sidebar can
show unread badges for Mentions, Participating, Unattended, and saved
conversation folders. Folder badges reuse the existing `custom_filters`
conversation filter semantics, store user-scoped Redis sets lazily, and
skip unsupported folder filters so invalid saved folders continue to
render without a badge. The Unattended badge counts all visible unread
open conversations that match the existing unattended conversation
scope.

Closes
-
[CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders)

## What changed

- Added user-scoped unread-count Redis keys and cache builders for
mentions, participating conversations, unattended conversations, and
saved folder filters.
- Reused `Conversations::FilterService` through a relation-returning
path so folder counts match the folder conversation list behavior.
- Invalidated user filter caches from mention, participant,
custom-filter, and relevant conversation update events.
- Extended the unread-count endpoint payload and sidebar Vuex/sidebar
rendering for the new badge counts, including the Unattended sidebar
item.
- Added Ruby, Enterprise, request, listener, and frontend store coverage
for the new unread-count dimensions.

## Type of change

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

## How Has This Been Tested?

- Created local validation folders for `john@acme.inc` and confirmed the
unread-count payload includes open, resolved, and high-priority folder
badges while excluding the invalid unsupported folder.
- Added coverage for the Unattended badge rule: all visible unread open
conversations matching `Conversation.unattended`.
- Ran focused unread-count Ruby specs, including service, listener,
request, and Enterprise counter coverage.
- Ran frontend unread-count store specs.
- Ran RuboCop on the touched Ruby files.
- Ran ESLint through the project script; it completed with warnings in
existing unrelated files and no errors.

<img width="369" height="525" alt="Screenshot 2026-06-13 at 10 51 39 PM"
src="https://github.com/user-attachments/assets/36b1d2c4-dac1-4f6f-9c0e-7ef5a6cc2975"
/>

## 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] Documentation changes are not required for this internal
unread-count behavior
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] No dependent downstream changes are required

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-16 23:46:49 +05:30
Sivin VargheseandGitHub 352f120c6a feat: add dedicated color tokens for voice call widget (#14745)
# Pull Request Template

## Description

This PR introduces dedicated color tokens for the floating voice call
widget instead of reusing generic design tokens. This allows the widget
to maintain a distinct dark-card appearance across both light and dark
themes.

Adds the following tokens and applies them to `CallCard`:

* `--call-widget`
* `--call-widget-border`
* `--call-widget-text`
* `--call-widget-sub-text`

Fixes
https://linear.app/chatwoot/issue/CW-7353/call-notification-window-theme-update-design

## Type of change

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

## How Has This Been Tested?

### Screenshots

**Light mode**
<img width="1466" height="815" alt="Screenshot 2026-06-16 at 3 47 32 PM"
src="https://github.com/user-attachments/assets/732164b1-5488-4cc1-8c71-01245d906842"
/>


**Dark mode**
<img width="1466" height="815" alt="Screenshot 2026-06-16 at 3 46 54 PM"
src="https://github.com/user-attachments/assets/536ab632-4c7c-486b-8fc4-a061ee40b22a"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-16 20:01:31 +05:30
Vishnu NarayananandGitHub a585853690 chore: expose advanced_search flag in super_admin UI for self-hosted (#14750)
## Description

Removes `chatwoot_internal: true` from the `advanced_search` feature
flag so self-hosted enterprise userss can toggle it from the super_admin
UI instead of going through a rails console.
2026-06-16 07:10:24 -07:00
64b0ebd8dc feat: Script to migrate images between providers (#9117)
# Pull Request Template

## Description

Storage migration functionality, which allows the transfer of images
from one on-premises provider to another (e.g. AWS, Google, etc.) using
Active Storage.
I ran the unit and integration tests and they all passed correctly.

**The command to execute the migration is `FROM=from_service
TO=to_service rake storage:migrate`**

Fixes #7907

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

I have tested the storage migration functionality by running the
provided unit and integration tests. Additionally, I manually tested the
migration process in a local development environment by following these
steps:

Set up two storage services (e.g., AWS S3 and Google Cloud Storage) with
valid configurations.
Execute the `FROM=local TO=amazon rake storage:migrate` task with
appropriate FROM and TO arguments to migrate blobs between the services.
Verified that the blobs were successfully transferred to the target
storage service.
Checked for any error messages or unexpected behavior during the
migration process.


## Checklist:

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-16 19:12:49 +05:30
Vishnu NarayananandGitHub cf6ee13665 feat: scale email rate limits by agent count for paid plans (#13966)
Paid plan accounts with many agents were hitting the flat daily email
rate limit cap.
This multiplies the plan base limit by the account's agent seat count,
so larger teams
get proportionally higher limits. Free/hacker plan keeps the flat limit
unchanged.

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

## How to test
1. Set up `ACCOUNT_EMAILS_PLAN_LIMITS` config with plan limits (e.g.,
`{"hacker": 10, "startups": 20, "business": 30, "enterprise": 40}`)
2. Create an account on a paid plan (e.g., startups) with multiple agent
seats
3. Verify `account.email_rate_limit` returns `base_limit × agent_seats`
(e.g., 20 × 5 = 100)
4. Create an account on the hacker plan — verify limit stays flat (10)
5. Set a per-account override via super admin `limits.emails` — verify
it takes priority over the multiplied limit

## What changed
- `plan_email_limit` now multiplies the base plan limit by agent seat
count for paid plans
- Added `free_plan?` helper to skip the multiplier for the default/free
plan
2026-06-16 18:56:30 +05:30
41a3ab6dfa feat(companies): add contact company selector (#14496)
Adds a company selector to the contact details form so agents can
associate a contact with an existing company directly from the contact
page.

Closes

- None

Why

Contacts already expose company information through the CRM fields, but
the form only accepted free-text company names. As we split company CRM
work into smaller PRs, this keeps the contact page aligned with the
structured company model while preserving the existing company-name
behavior used by automations.

What changed

- Shows a company dropdown in the contact details form when the
Companies feature is enabled.
- Keeps legacy free-text company names editable when a contact has no
structured `company_id`.
- Allows Enterprise contact create/update APIs to accept account-scoped
`company_id`.
- Syncs `additional_attributes.company_name` when a contact is
associated with a company, including the existing email-domain
auto-association path.
- Serializes `company_id` in the contact model payload so the form can
show the current association.

How to test

1. Enable Companies for an account and open a contact details page.
2. In Edit contact details, use the Company field to select an existing
company.
3. Save the contact and refresh the page.
4. Confirm the selected company remains visible and the contact is
associated with that company.
5. Confirm contacts with only a legacy free-text company name still show
the text input instead of an empty selector.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-16 15:29:27 +05:30
de137e8297 feat: Capture CTWA referral metadata for WhatsApp conversations (#14681)
Adds support for capturing Click-to-WhatsApp ad referral metadata from
incoming WhatsApp messages.

This stores Meta Cloud API `referral` payloads on the incoming message
`content_attributes` and normalizes Twilio `Referral*` callback fields
into the same shape. The UI display is intentionally deferred until
Instagram, Messenger, and TikTok referral payloads are captured as well,
so we can design one cross-channel referral surface instead of a
WhatsApp-only sidebar block.

Why message-level storage

Meta sends CTWA referral details as part of the inbound message payload,
not as a stable conversation-level webhook. The referral represents the
exact ad click that produced that specific customer message, and later
messages in the same conversation may not carry the same context.
Storing the normalized referral on the message preserves the original
webhook semantics, avoids adding conversation-level attribution that
could become stale or ambiguous, and keeps support for both Cloud API
and Twilio payloads aligned behind `content_attributes.referral`.
Fixes
https://linear.app/chatwoot/issue/CW-7090/surface-meta-ctwa-referral-on-incoming-whatsapp-messages-cloud-api
Closes https://github.com/chatwoot/chatwoot/issues/13995,
https://github.com/chatwoot/chatwoot/issues/12560,
https://github.com/chatwoot/chatwoot/issues/13006


Related community PRs
- https://github.com/chatwoot/chatwoot/pull/13130
- https://github.com/chatwoot/chatwoot/pull/14180
- https://github.com/chatwoot/chatwoot/pull/14121

Related follow-ups
-
https://linear.app/chatwoot/issue/CW-7301/capture-instagram-ad-referral-metadata-on-incoming-messages
-
https://linear.app/chatwoot/issue/CW-7302/capture-messenger-ad-referral-metadata-on-facebook-page-conversations
-
https://linear.app/chatwoot/issue/CW-7303/capture-tiktok-ad-referral-metadata-from-im-referral-msg-events

How to test
1. Send or replay a WhatsApp Cloud API inbound message that contains a
`messages[0].referral` payload from a Click-to-WhatsApp ad.
2. Confirm the generated incoming message stores the payload under
`content_attributes.referral`.
3. Repeat with a Twilio WhatsApp callback containing `Referral*` fields
and confirm the stored message has the same normalized
`content_attributes.referral` structure.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-06-16 09:58:29 +04:00
Sivin VargheseandGitHub a3d05ef55d chore: Update emoji picker in widget (#14741) 2026-06-16 10:39:47 +05:30
dda25c0b51 fix(portal): emit valid BCP 47 html lang attribute (#14680)
Locales are stored with underscores (e.g. pt_BR), but the HTML lang
attribute requires hyphens (pt-BR). Convert via a helper in the public
portal layouts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-15 14:34:52 -07:00
Sivin VargheseandGitHub 1713cabdf2 feat: Add emoji & icon picker for Help Center categories (#14702) 2026-06-16 02:10:05 +05:30
d8a16278b9 fix: added ordering capability for sidebar sections folders, teams, channels and labels (CW-7193) (#14609)
## Description

* Added the ability to sort for 4 sub-sections under conversations
folders, teams, channels and labels.
* the sort options are basically created at, alphabetical and unread
counts along with both directions.
* for folders we don't have an unread count, so we sort it by only
created and alphabetical.
* all the sort preferences are stored on the frontend - easiest
implementation for now.

Fixes # CW-7193

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Tested this locally by visually verifying the changes. Also ran the
newly added tests for component level changes.

Here is the screenshot of the changes:

Added the sort option to sub sections in the conversation sidebar:
<img width="282" height="848" alt="Screenshot 2026-06-02 at 1 58 12 AM"
src="https://github.com/user-attachments/assets/4a7c6061-86e3-438a-92ae-ee643a0128b6"
/>

The sort options looks like this:
<img width="783" height="698" alt="Screenshot 2026-06-02 at 1 58 49 AM"
src="https://github.com/user-attachments/assets/a15bb0a7-b810-4423-a88c-fbd84d0476c0"
/>


## Checklist:

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

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-06-15 21:34:19 +05:30
Sony MathewandGitHub 4816e923b6 chore: added a new sort by unread option for conversations (CW-7152) (#14512)
## Description

Added a new option for the sort by option in conversation filters called
unread. This is to filter out unread conversations.
Fixes # CW-7152

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Tested manually in local.

<img width="1397" height="603" alt="Screenshot 2026-05-20 at 10 40
20 PM"
src="https://github.com/user-attachments/assets/6c60263e-907b-419f-a7ed-06010bbe8736"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-15 17:58:04 +05:30
396631ad7d feat: enforce concurrent session limit with login picker (CW-7169) (#14621)
## Description

Cap active sessions at `MAX_USER_SESSIONS` which defaults to existing
value of `25` per user. This ensure existing user login behavior is not
affected for self-hosted installations. Browser users at the cap see a
session picker (409 response) to choose which session to end.
Non-browser clients and partially-tracked users get silent
oldest-session eviction.

Depends on #14556.

## Type of change

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

## How Has This Been Tested?

Specs cover: under limit, at limit (browser picker, non-browser
eviction), partial tracking fallback, revoke single/all sessions during
login, session row creation on successful login.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-15 17:28:13 +05:30
Vishnu NarayananandGitHub ba0ba46c9c fix: skip session tracking and use short-lived token for impersonation (CW-7169) (#14622)
## Description

SuperAdmin impersonation SSO logins no longer create UserSession rows
visible to the customer. Impersonation tokens use a 2-day lifespan
instead of ~2 months, so they naturally evict first and don't linger in
the user's token list.

Server-side detection via Redis value (`'impersonation'` vs `'normal'`)
without changing the `valid_sso_auth_token?` signature. Backward
compatible with in-flight tokens.

Depends on #14556.

## Type of change

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

## How Has This Been Tested?

Specs cover: impersonation login skips UserSession creation,
impersonation token has short lifespan, normal SSO login still creates
UserSession row.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-15 17:14:34 +05:30
Vishnu NarayananandGitHub ee6382109a feat: prevent deleted email conversations from syncing again (#14612)
# Pull Request Template

## Description

Prevent deleted email conversations from being synced into Chatwoot
again while they are still within the IMAP sync window.

When an admin explicitly deletes an email conversation, the incoming
email message IDs are stored temporarily in Redis. IMAP sync checks
these recently deleted message IDs in addition to existing message
records. Each Redis key expires automatically after two days.

This applies only to explicit conversation deletion. Individual message
deletion, inbox deletion, and account deletion keep their existing
behavior.

Fixes
[CW-7214](https://linear.app/chatwoot/issue/CW-7214/deleted-mails-in-gmail-inbox-gets-synced-again)
2026-06-15 17:06:40 +05:30
Sivin VargheseandGitHub 35bef21f83 feat: add media view for contacts (#14393) 2026-06-15 15:42:11 +05:30
e5c140158e fix(new-conversation): stop writing literal "undefined" as mail_subject (#14383)
## Description

This PR fixes a data-corruption bug in the new conversation flow that
surfaces in conversation search results as `Subject: undefined`.

### The Problem
When creating a conversation through the "New conversation" modal
without typing a subject (every non-email channel never shows the
subject input, and email channels can be left blank), the conversation
gets persisted with `additional_attributes.mail_subject = "undefined"`
(the literal string). The bogus value shows up in conversation search
results as `Subject: undefined`, which started rendering after #10843.

### Root Cause
`createConversationPayload` in
`app/javascript/dashboard/store/modules/contactConversations.js` appends
the `mail_subject` field unconditionally:

```js
payload.append('additional_attributes[mail_subject]', mailSubject);
```

`composeConversationHelper.js` only sets `payload.mailSubject` when
`subject` is truthy, so when no subject is provided, `mailSubject` is
destructured as `undefined` in `createConversationPayload`.
`FormData.append` coerces `undefined` to the string `"undefined"`, and
the backend persists it as-is into the JSONB column.

### The Fix
Skip the append when `mailSubject` is falsy. The backend already treats
a missing key the same as an empty subject (the email mailer falls back
to a default subject when `mail_subject` is `nil`), so omitting the
field is safe across channels.

### Key Changes
- Guarded the `additional_attributes[mail_subject]` append in
`createConversationPayload`.
- Added a spec covering the case where `mailSubject` is omitted.

> Note: existing rows persisted before this fix will continue to show
`Subject: undefined` in search until cleaned up. A simple SQL cleanup
for non-email inboxes:
>
> ```sql
> UPDATE conversations
> SET additional_attributes = additional_attributes - 'mail_subject'
> FROM inboxes
> WHERE conversations.inbox_id = inboxes.id
>   AND inboxes.channel_type <> 'Channel::Email'
> AND conversations.additional_attributes->>'mail_subject' =
'undefined';
> ```
>
> I'm leaving any data-cleanup migration out of this PR since it's a
maintenance concern that may be handled differently per installation.

## Type of change

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

## How Has This Been Tested?

1. Open the "New conversation" modal on a non-email inbox (e.g.,
WhatsApp, SMS, API).
2. Create a conversation without filling any subject (field is not
present on those, so regular flow).
3. Open the global search and search for the conversation.
4. **Before:** the result card shows `Subject: undefined`.
   **After:** the subject row is hidden.
5. Repeat on an email inbox leaving the subject blank — same result.
6. On an email inbox, type a subject and verify it still persists and
renders correctly.
7. Run the new spec: `pnpm test contactConversations`.

## Checklist:

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

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-14 08:57:38 +05:30
274e92e0e4 chore: collapse conversation sidebar sections (folders, teams, inboxes and labels) - CW-7059 (#14509)
## Description

Added ability to collapse conversation sidebar sections (folders, teams,
inboxes and labels)

Fixes #CW-7059

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Tested locally. Added specs. Attaching the loom for them same.


https://github.com/user-attachments/assets/40d613e7-6c82-4078-abf4-79739a00f718


## Checklist:

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

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-06-14 06:54:18 +05:30
27404b4a27 fix: redact sensitive integration secrets from API responses (#14147)
## Summary

The `GET /api/v1/accounts/:id/integrations/apps` endpoint returns raw
secret values (OpenAI API keys, Google service account private keys,
Linear refresh tokens, etc.) in hook settings within the JSON response.
Although gated behind an administrator check, these secrets are visible
in the browser network tab.

This PR filters hook settings through the existing `visible_properties`
whitelist defined in `config/integration/apps.yml`, and adds explicit
whitelists to integrations that were missing them.

Closes #14042

## Bug reproduction

**Setup:** Created an OpenAI integration hook with a fake API key
(`sk-test-secret-12345`).

**Step 1 — Browser Network tab shows raw secrets:**

<img width="1676" height="869" alt="Screenshot 2026-04-24 at 14 32 28"
src="https://github.com/user-attachments/assets/6fc69463-365e-458b-b216-98a7390bf528"
/>


Navigate to Settings > Integrations as an admin. Open DevTools Network
tab and observe the response from `GET
/api/v1/accounts/:id/integrations/apps`. The hook settings contain the
full API key in plaintext:

```json
"hooks": [
  {
    "id": 1,
    "app_id": "openai",
    "settings": {
      "api_key": "sk-test-secret-12345",
      "label_suggestion": false
    }
  }
]
```

**Step 2 — API call confirms the leak:**

```bash
curl -s "http://localhost:3000/api/v1/accounts/2/integrations/apps" \
  -H "access-token: <token>" \
  -H "client: <client>" \
  -H "uid: user@test.com" \
  | jq '.payload[] | select(.id == "openai") | {id, name, hooks: [.hooks[] | {id, app_id, settings}]}'
```

Response:

```json
{
  "id": "openai",
  "name": "OpenAI",
  "hooks": [
    {
      "id": 1,
      "app_id": "openai",
      "settings": {
        "api_key": "sk-test-secret-12345",
        "label_suggestion": false
      }
    }
  ]
}
```

Any admin user can extract the raw API key from the response. The same
applies to other integrations — Dialogflow exposes full Google service
account credentials, Linear exposes refresh tokens, etc.

## After fix

After applying this change, the GET
/api/v1/accounts/:id/integrations/apps endpoint no longer returns
sensitive secret values in hook settings. Instead, the response is
filtered using each integration’s visible_properties whitelist, ensuring
only safe, user-facing fields are exposed. For example, OpenAI
integrations return non-sensitive fields like label_suggestion while
excluding raw API keys. This prevents secrets from being exposed in the
browser network tab or API responses, even for authenticated admin
users.

```bash
curl -X GET "http://localhost:3000/api/v1/accounts/2/integrations/apps" \
  -H "Accept: application/json" \
  -H "Authorization: Bearer eyJhY2Nlc3MtdG9rZW4iOiJqZG5jOWg4TnljaWFJa3JlZkxGQzRnIiwidG9rZW4tdHlwZSI6IkJlYXJlciIsImNsaWVudCI6Ik81bjVnTDFEOVVhbGpwbWxjaHZNanciLCJleHBpcnkiOiIxNzgyMzMyNTA4IiwidWlkIjoidXNlckB0ZXN0LmNvbSJ9" \
  -H "access-token: jdnc9h8NyciaIkrefLFC4g" \
  -H "client: O5n5gL1D9UaljpmlchvMjw" \
  -H "uid: user@test.com" \
  | jq '.payload[] | select(.id == "openai") | {id, name, hooks: [.hooks[] | {id, app_id, settings}]}'
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 10174  100 10174    0     0  33232      0 --:--:-- --:--:-- --:--:-- 33357
{
  "id": "openai",
  "name": "OpenAI",
  "hooks": [
    {
      "id": 1,
      "app_id": "openai",
      "settings": {
        "label_suggestion": false
      }
    }
  ]
}
```

## What changed

- Added `visible_properties` accessor to `Integrations::App` model to
expose the whitelist from config
- Updated `_hook.json.jbuilder` to filter `resource.settings` through
the associated app's `visible_properties` instead of returning the full
hash
- Added `visible_properties` to integrations that were missing it:
  - Linear: `[]` (settings contain refresh_token)
  - Notion: `[]` (OAuth-based, no user-facing settings)
- Slack: `['channel_name']` (UI needs this to display connected channel)
  - Shopify: `[]` (no settings)

App config metadata (`_app.json.jbuilder`) is left unchanged —
hook_type, settings_form_schema, etc. are not secrets and the frontend
depends on them.

## How to test

1. Create an OpenAI integration hook with an API key
2. As an admin, call `GET /api/v1/accounts/:id/integrations/apps`
3. Verify hook settings include `api_key` (whitelisted) but not raw
credential objects
4. For Dialogflow hooks, verify `credentials` (private key JSON) is
excluded while `project_id` is included
5. For Slack hooks, verify `channel_name` is still returned
6. For Linear hooks, verify `refresh_token` is not returned

---------

Co-authored-by: Botshelo Nokoane (Konstruktors) <botshelo@entersekt.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-14 06:53:13 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony MathewSony Mathew
006b529918 chore(deps): bump net-imap from 0.4.24 to 0.6.4.1 (#14688)
Bumps [net-imap](https://github.com/ruby/net-imap) from 0.4.24 to
0.6.4.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ruby/net-imap/releases">net-imap's
releases</a>.</em></p>
<blockquote>
<h2>v0.5.15</h2>
<h2>What's Changed</h2>
<h3>🔒 Security</h3>
<p>This release fixes several more security vulnerabilities which are
related to the fixes in <code>v0.5.14</code>. Please see the linked
security advisories for more information.</p>
<ul>
<li><em>(moderate)</em> Command Injection via non-synchronizing literal
in &quot;raw&quot; argument (CVE-2026-47240, GHSA-8p34-64r3-mwg8)
This vulnerability depends how the server interprets non-synchronizing
literals.
The connection is <em>not</em> vulnerable if the server supports
non-synchronizing literals.
<ul>
<li>🥅 Validate non-synchronizing literals support by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/703">ruby/net-imap#703</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/701">ruby/net-imap#701</a>)</li>
</ul>
</li>
<li><em>(moderate)</em> Command Injection via unvalidated ID and ENABLE
arguments (CVE-2026-47242, GHSA-46q3-7gv7-qmgg)
<ul>
<li>🥅 Validate <code>ID</code> values contain only valid bytes by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/703">ruby/net-imap#703</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/698">ruby/net-imap#698</a>)</li>
<li>🥅 Validate <code>#enable</code> arguments are all atoms by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/703">ruby/net-imap#703</a>
(backport <a
href="https://redirect.github.com/ruby/net-imap/pull/699">ruby/net-imap#699</a>)
<strong>NOTE:</strong> <code>#enable</code> should
<strong><em>never</em></strong> be called with untrusted input.</li>
</ul>
</li>
<li><em>(low)</em> Denial of Service via incomplete &quot;raw&quot;
argument validation (CVE-2026-47241, GHSA-c4fp-cxrr-mj66)
This results in the affected command hanging until the connection is
closed. If another thread attempts to send a concurrent pipelined
command, the first thread will return with a syntax error and the second
thread will hang until the connection closes.
<ul>
<li><em>Reported by <a
href="https://github.com/fg0x0"><code>@​fg0x0</code></a></em></li>
<li>🐛 Prevent trailing <code>{0}</code> in RawData validation by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/703">ruby/net-imap#703</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/700">ruby/net-imap#700</a>)</li>
</ul>
</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>🥅 Validate that Atom and Flag are not empty by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/685">ruby/net-imap#685</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/684">ruby/net-imap#684</a>)</li>
<li>🧵 Fix deadlock in <code>#disconnect</code> by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/697">ruby/net-imap#697</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/686">ruby/net-imap#686</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>⚠️ Boost visibility of raw data argument documentation warnings by
<a href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/696">ruby/net-imap#696</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/677">ruby/net-imap#677</a>)</li>
</ul>
<h3>Other Changes</h3>
<ul>
<li>🏷️ Allow 64-bit Integer arguments in <a
href="https://redirect.github.com/ruby/net-imap/pull/696">ruby/net-imap#696</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/675">ruby/net-imap#675</a>)</li>
<li>🥅 Ensure send_number_data input is an Integer in <a
href="https://redirect.github.com/ruby/net-imap/pull/696">ruby/net-imap#696</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/676">ruby/net-imap#676</a>)</li>
<li>♻️ Improve <code>RawData.new</code>, Add <code>RawData.split</code>
by <a href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/696">ruby/net-imap#696</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/679">ruby/net-imap#679</a>)</li>
<li>🥅 Validate response literal byte size format by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/696">ruby/net-imap#696</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/681">ruby/net-imap#681</a>)</li>
</ul>
<h3>Miscellaneous</h3>
<ul>
<li> Improvements to tests' FakeServer in <a
href="https://redirect.github.com/ruby/net-imap/pull/696">ruby/net-imap#696</a>
(backports <a
href="https://redirect.github.com/ruby/net-imap/pull/678">ruby/net-imap#678</a>)</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ruby/net-imap/compare/v0.5.14...v0.5.15">https://github.com/ruby/net-imap/compare/v0.5.14...v0.5.15</a></p>
<h2>v0.5.14</h2>
<h2>What's Changed</h2>
<h3>🔒 Security</h3>
<p>This release contains fixes for <strong>multiple
vulnerabilities</strong> concerning <em><strong><code>STARTTLS</code>
stripping</strong></em>, argument validation, and denial of service
attacks.</p>
<blockquote>
<p>[!WARNING]
<a
href="https://redirect.github.com/ruby/net-imap/pull/665">ruby/net-imap#665</a>
fixes a <code>STARTTLS</code> stripping vulnerability
(GHSA-vcgp-9326-pqcp).
Without this fix, a man-in-the-middle attacker can cause
<code>Net::IMAP#starttls</code> to return &quot;successfully&quot;,
<strong><em>without starting TLS</em></strong>.</p>
</blockquote>
<blockquote>
<p>[!IMPORTANT]
Argument validation is significantly improved. Several command injection
vulnerabilities have been fixed:
<a
href="https://redirect.github.com/ruby/net-imap/pull/662">ruby/net-imap#662</a>
fixes CRLF/command/argument injection via Symbol arguments
(GHSA-75xq-5h9v-w6px).
<a
href="https://redirect.github.com/ruby/net-imap/pull/662">ruby/net-imap#662</a>
fixes CRLF/command/argument injection via the <code>attr</code> argument
to <code>#store</code>/<code>#uid_store</code> (GHSA-hm49-wcqc-g2xg)</p>
</blockquote>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ruby/net-imap/commit/ce20fc8e9974ed87d0848dc411eef490623cdeb9"><code>ce20fc8</code></a>
🔖 Bump version to 0.5.15</li>
<li><a
href="https://github.com/ruby/net-imap/commit/0b7b83c19312dd3f44cb72be2e7681289324d9db"><code>0b7b83c</code></a>
🔀 Merge pull request <a
href="https://redirect.github.com/ruby/net-imap/issues/703">#703</a>
from ruby/backport/v0.5/security-patches</li>
<li><a
href="https://github.com/ruby/net-imap/commit/f22fd6cee01140b53cdb7fc8d42a352a734f4509"><code>f22fd6c</code></a>
🍒 pick 0ea9eba3 (<a
href="https://redirect.github.com/ruby/net-imap/issues/701">#701</a>): 
Fix flaky tests for MacOS, TruffleRuby</li>
<li><a
href="https://github.com/ruby/net-imap/commit/12460740451d064cef84f59dd8c91baba0388ae1"><code>1246074</code></a>
🍒 pick ae9f83b5 (<a
href="https://redirect.github.com/ruby/net-imap/issues/701">#701</a>):
♻️ Extract str.bytesize lvar in send_literal</li>
<li><a
href="https://github.com/ruby/net-imap/commit/a2f61af6b7a7e6f0e4f27691531de6e4861318cd"><code>a2f61af</code></a>
🍒 pick 62a0da6d (<a
href="https://redirect.github.com/ruby/net-imap/issues/701">#701</a>): 🥅
Validate non-synchronizing literals support</li>
<li><a
href="https://github.com/ruby/net-imap/commit/e33348ccdc639d0d4414f72e4ba7024bdb24bf29"><code>e33348c</code></a>
🍒 pick d6ddd294 (<a
href="https://redirect.github.com/ruby/net-imap/issues/700">#700</a>): 🐛
Prevent trailing <code>{0}</code> in RawData validation</li>
<li><a
href="https://github.com/ruby/net-imap/commit/4f81b69044297de02ada859fffba6ad9923a43a5"><code>4f81b69</code></a>
🍒 pick 1f97168b (<a
href="https://redirect.github.com/ruby/net-imap/issues/699">#699</a>): 🥅
Validate <code>#enable</code> arguments are all atoms</li>
<li><a
href="https://github.com/ruby/net-imap/commit/69da4a490dd148526fb287a5c82056bfd20c6f95"><code>69da4a4</code></a>
🍒 pick 8d9397ab (<a
href="https://redirect.github.com/ruby/net-imap/issues/698">#698</a>): 🥅
Validate QuotedString contains only valid bytes</li>
<li><a
href="https://github.com/ruby/net-imap/commit/7aab580ad35f2cad1404a58bf0ff015567f2593c"><code>7aab580</code></a>
🍒 pick e3c50fad (<a
href="https://redirect.github.com/ruby/net-imap/issues/698">#698</a>):
♻️ Refactor RawText, add improve test coverage</li>
<li><a
href="https://github.com/ruby/net-imap/commit/fac1733d45ec57da3675c4d266c771c73c7265bb"><code>fac1733</code></a>
🍒 pick aab64f92 (<a
href="https://redirect.github.com/ruby/net-imap/issues/686">#686</a>): 🧵
Fix deadlock in <code>#disconnect</code></li>
<li>Additional commits viewable in <a
href="https://github.com/ruby/net-imap/compare/v0.4.24...v0.5.15">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=net-imap&package-manager=bundler&previous-version=0.4.24&new-version=0.5.15)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

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

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

---

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

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

</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-13 22:49:03 +05:30
Sony MathewandGitHub b1c2db5435 fix: Stabilize help center builder error spec (#14725)
## Description

Stabilizes the enterprise help center article builder source URL
validation spec by asserting the custom exception via its class name and
message text. This keeps the spec focused on the intended behavior while
avoiding brittle custom exception constant identity checks in
CI/reloading environments.

A bunch of builds on different PRs have been failing because of this
error, sample traces are below:
*
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114064/workflows/bdb6eca9-3b65-4c38-b8cf-f2f8564476f8/jobs/158777
*
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114064/workflows/bdb6eca9-3b65-4c38-b8cf-f2f8564476f8/jobs/158777

Fixes # N/A

## Type of change

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

## How Has This Been Tested?

- `/Users/sonymathew/.rbenv/shims/bundle exec rspec
spec/enterprise/services/onboarding/help_center_article_builder_spec.rb`
- `/Users/sonymathew/.rbenv/shims/bundle exec rubocop
spec/enterprise/services/onboarding/help_center_article_builder_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] New and existing unit tests pass locally with my changes
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-13 19:57:42 +05:30
Vishnu NarayananandGitHub 92a1fb8ab7 feat: manage active user sessions from profile (CW-7169) (#14556)
## Description

First PR of user_sessions feature - enforcement, impersonation and mfa will be handled separately.

Adds an Active Sessions section under Profile where users can see every
device currently logged in and revoke any session they don't recognize.
Helps users lock down stale or unrecognized logins on their own without
needing support.

**Behavior at the limit, by client:**
- **Browser:** returns 409 with a picker overlay; user picks a session
to revoke or chooses "End all sessions" to clear them.

- **Mobile / API client:** silently evicts the oldest session and
proceeds with login (no picker UI to render).
- **Pre-tracking users** (token rows without `user_sessions`, i.e.
anyone already logged in before this ships): silent-evict any untracked
token first, so freshly tracked sessions are never killed in favor of
legacy ones.

Sessions are stored in a new `user_sessions` table keyed on `(user_id,
client_id)` with browser, platform, IP, last activity and (when
configured) geo. Kept in sync with `user.tokens` via an after_save
callback so revoking a token from any path cleans up the row.

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

## Type of change

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

## How Has This Been Tested?

- Added specs.
- Manual local testing: browser picker fires at limit; pre-tracking user
silent-evicts; mixed tracked/untracked correctly drops the untracked one
first; profile page revoke succeeds; current session cannot be revoked
from profile.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-12 16:11:07 +05:30
e055cead35 fix: only subscribe calls webhook field when voice calling enabled (#14718)
## Description

Solves issue https://github.com/chatwoot/chatwoot/issues/14690

## Type of change

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

## How has this been tested?

- UI flows

## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-12 12:25:25 +05:30
c041fde3a2 fix: Preserve original filenames for WhatsApp cloud attachments (#14168)
## Summary

Preserves the original filename provided in the WhatsApp Cloud
attachment payload when downloading media files.

## Problem

Files containing accented or special characters could be saved with
malformed names or incorrect extensions due to relying on remote
download metadata.

## Changes Made

- Uses attachment_payload[:filename] when present
- Creates a tempfile using the original filename and extension
- Preserves content type metadata
- Falls back to existing behavior when no filename is provided

## Notes

This should improve attachment downloads for filenames containing
accented characters.
Related to #10973

---------

Co-authored-by: Rorrick Smith <rorrick@bulksms.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-12 10:21:42 +04:00
c6a38e2fc6 fix: Keep Instagram scopes out of new Messenger OAuth flows (#14695)
Removes Instagram permissions from the Facebook Messenger inbox setup
flow to avoid blocking Meta App Review for Messenger-only apps.

Meta rejects or stalls Messenger-only app reviews when the OAuth flow
requests unrelated Instagram permissions such as `instagram_basic` and
`instagram_manage_messages`. This blocks approval for Messenger
permissions like `human_agent`, even when the user only wants to
configure a Facebook Messenger inbox.

New Facebook Messenger inbox setup now requests only the Page and
Messenger permissions required for Messenger. Existing Facebook
Messenger inboxes that already have Instagram details continue to
request Instagram permissions during reauthorization, preserving support
for the legacy combined Facebook/Instagram inbox flow.

Going forward, new Instagram setups should use the dedicated Instagram
channel inbox instead of being bundled into the Messenger setup flow.

Closes #13860

## How to test

1. Go to Settings → Inboxes → Add Inbox → Facebook Messenger.
2. Click Login with Facebook.
3. Verify the OAuth scope does not include `instagram_basic` or
`instagram_manage_messages`.
4. Reauthorize an existing Facebook Messenger inbox with `instagram_id`
present.
5. Verify the reauth scope still includes `instagram_basic` and
`instagram_manage_messages`.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-06-12 09:39:26 +04:00
Sugar Ray LedesmaandGitHub 2663a8495d refactor(security): rename path_without_extensions in Rack::Attack (#14216)
Fix misspelling 'extentions' and clarify comments. Behavior unchanged:
same path-stripping logic for throttle matching (e.g. /auth and
/auth.json). Also correct 'You may' in remote_ip comment.
2026-06-11 18:36:34 +05:30
Sivin VargheseandGitHub d0d275d962 fix: cannot resize pasted images (#14709) 2026-06-11 16:47:11 +05:30
95d6aecb51 chore: Add security flags to session cookie configuration (#14248)
## Summary
- Adds `httponly` and `secure` flags to session cookie configuration
- Adds RSpec tests for session configuration

## Changes
- `config/initializers/session_store.rb`: add `httponly: true`, `secure:
FORCE_SSL`
- `spec/config/session_store_spec.rb`: tests for all session store
options

Ref #2683 (hardens the session cookie but does not remove it, sessions
are still needed for super_admin dashboard)

---------

Co-authored-by: Adam-Relay <adam@userelay.ai>
Co-authored-by: Adam Berger <adam@adam-berger.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-06-11 16:13:57 +05:30
a901c87ab4 perf: merge 3 conversation COUNT queries in /meta into single query (#13536)
# Pull Request Template

## Description


- Merges 3 separate COUNT queries in
`ConversationFinder#set_count_for_all_conversations` into a single query
using PostgreSQL `COUNT(*) FILTER (WHERE ...)`
- Every call to `/meta` and conversation index fires 3 COUNT queries
against the conversations table — one for "mine", one for "unassigned",
one for "all". These share the same base query and scan the same rows,
but execute as 3 independent round-trips.

from pg_stats

| Query | Calls | Total DB Time | Avg Latency |
|-------|-------|--------------|-------------|
| COUNT unassigned | 4.1M | 141,081 sec | 34ms |
| COUNT all | 4.1M | 132,302 sec | 32ms |
| COUNT mine | 3.7M | 37,637 sec | 10ms |
| **Total** | **~12M** | **311,020 sec** | |

`/meta` alone runs at 2.3K RPM (NewRelic), triggered on every WebSocket
event (conversation created/updated/status changed/assignee changed).



Fixes
https://linear.app/chatwoot/issue/INF-43/make-single-query-to-count-the-assigned-unassigned-and-all-instead-of

## Type of change

- [x] Performance fix

## How Has This Been Tested?

- [x] Verify conversation list shows correct
mine/unassigned/assigned/all counts
- [x] Verify /meta endpoint returns correct counts
- [x] Verify counts update correctly when assigning/unassigning
conversations
- [x] Verify counts with team filter applied
- [x] Monitor DB query count reduction in NewRelic after deploy

## Checklist:

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

---------

Co-authored-by: Sony Mathew <ynos1234@gmail.com>
2026-06-11 16:10:48 +05:30
Sony MathewandGitHub 6d26e7930a fix(contacts): truncate inbound contact names (IMAP sender display names) (#14631)
## Description

Truncates long contact names in `ContactInboxWithContactBuilder` before
persisting them, so inbound IMAP sender display names over the
`contacts.name` 255-character limit no longer raise
`ActiveRecord::RecordInvalid` from `Inboxes::FetchImapEmailsJob`. This
keeps email ingestion moving while preserving the existing contact name
length constraint.

Linear ticket:
[https://linear.app/chatwoot/issue/CW-7263/sentry-active-record-invalid-contact-create-from-imap-fetch-job](https://linear.app/chatwoot/issue/CW-7263/sentry-active-record-invalid-contact-create-from-imap-fetch-job)

## Type of change

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

## How Has This Been Tested?

- [x] `bundle exec rspec
spec/builders/contact_inbox_with_contact_builder_spec.rb`
- [x] `bundle exec rspec spec/mailboxes/imap/imap_mailbox_spec.rb`
- [x] `bundle exec rubocop
app/builders/contact_inbox_with_contact_builder.rb
spec/builders/contact_inbox_with_contact_builder_spec.rb`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
2026-06-11 15:15:14 +05:30
Tanmay Deep SharmaandGitHub 8d5d02ea97 feat: add per-inbox toggle to disable incoming calls (#14645)
## Description

Adds a per-inbox "Allow incoming calls" toggle for voice-enabled
WhatsApp and Twilio inboxes. When turned off, the setting is persisted
on the channel; actually rejecting inbound calls is handled in a
follow-up PR.

## Type of change

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

## Screenshot
<img width="804" height="384" alt="Screenshot 2026-06-04 at 11 44 07 AM"
src="https://github.com/user-attachments/assets/df8bb026-0387-4031-bcba-6d9a56872eb7"
/>


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-11 14:22:44 +05:30
ce93ddec78 fix: re-fetch widget conversation status on reconnect (#14683)
When an inbox has **"Allow messages after conversation is resolved"**
disabled, the live-chat widget should hide the reply box once a
conversation is resolved — but users could still send a reply, silently
reopening it. This syncs the widget's conversation status on reconnect
so the reply box hides correctly.

## Closes
- CW-7272

## How to reproduce
1. Disable **Allow messages after conversation is resolved** on an
inbox.
2. Open the widget, then let its websocket drop (background tab / lose
network).
3. While disconnected, get the conversation resolved (e.g. auto-resolve
on inactivity).
4. Reconnect → reply box is still visible and a sent message reopens the
resolved conversation.

## Why
Reply-box hiding is gated on the widget's local status, updated via the
`conversation.status_changed` socket event. If the resolve happens while
disconnected, the event is missed — and on reconnect the widget only
re-synced messages, never the status, so it stayed stale at `open`.

## What changed
- `onReconnect` now also dispatches
`conversationAttributes/getAttributes` to refresh status after a missed
event.
- Added a spec covering the reconnect behavior.

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-11 14:09:13 +05:30
Sivin VargheseandGitHub 940428c10e fix: preserve markdown links with unsafe href characters in help center content (#14686) 2026-06-11 13:47:08 +05:30
4d3196b02f feat: Show unread count for all conversations (#14627)
## Description

Adds the unread count for All Conversations to the left sidebar. The
unread counts API now returns an `all_count` aggregate based on the same
permission-scoped inbox counts already used for sidebar badges, and the
sidebar renders that backend-provided value on the All Conversations
item.

Fixes
[CW-7240](https://linear.app/chatwoot/issue/CW-7240/unread-count-for-all-conversations)

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec
spec/services/conversations/unread_counts/counter_spec.rb
spec/enterprise/services/conversations/unread_counts/counter_spec.rb
spec/controllers/api/v1/accounts/conversations_controller_spec.rb`
- `pnpm test
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js`
- `pnpm exec eslint
app/javascript/dashboard/components-next/sidebar/Sidebar.vue
app/javascript/dashboard/store/modules/conversationUnreadCounts.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js`
- `bundle exec rubocop
app/services/conversations/unread_counts/counter.rb
spec/services/conversations/unread_counts/counter_spec.rb
spec/enterprise/services/conversations/unread_counts/counter_spec.rb
spec/controllers/api/v1/accounts/conversations_controller_spec.rb`

## Checklist:

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

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-11 13:05:52 +05:30
Tanmay Deep SharmaandGitHub c6e5657ee5 fix(call): emit assignee activity message when agent answers a call (#14700)
## Linear Ticket
- https://linear.app/chatwoot/issue/CW-7249/debug-assignment-log

## Description

When an agent answers an inbound WhatsApp call on an unassigned
conversation, the conversation is claimed for that agent — but no
"assigned" activity message or assignee-changed event was emitted, so
the assignment was invisible in the timeline (and notifications/live
assignee panel didn't update).

The claim ran before `update_conversation_call_status`, so that later
`update!` on the same conversation clobbered
`saved_change_to_assignee_id?` before `after_commit` fired. Moving the
claim to be the conversation's final write in the transaction restores
the activity message, the `ASSIGNEE_CHANGED` event, and the live
assignee update.

## Type of change

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

## How Has This Been Tested?

1. Open an unassigned WhatsApp-call conversation.
2. As an agent, answer an inbound call.
3. Before: the conversation is assigned to you, but no "self-assigned"
activity message appears. After: the activity message is created and the
assignee updates live.

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-11 11:16:41 +05:30
2013 changed files with 98708 additions and 11248 deletions
+7 -4
View File
@@ -77,7 +77,8 @@ jobs:
- node/install:
node-version: '24.13'
- node/install-pnpm
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -118,7 +119,8 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -144,12 +146,13 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 20
parallelism: 18
steps:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
+3 -3
View File
@@ -272,9 +272,9 @@ AZURE_APP_SECRET=
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features
## OpenAI key
# OPENAI_API_KEY=
# AI powered features (Captain)
# The OpenAI API key and endpoint for Captain are not configured via .env.
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT).
# Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes
+10 -5
View File
@@ -43,13 +43,18 @@
## General Guidelines
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
- Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
@@ -80,8 +85,8 @@
## Project-Specific
- **Translations**:
- Only update `en.yml` and `en.json`
- Other languages are handled by the community
- For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community
- Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
- **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated)
+1 -1
View File
@@ -195,7 +195,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.10.0'
gem 'ai-agents', '>= 0.12.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'
+34 -32
View File
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.10.0)
ai-agents (0.12.0)
ruby_llm (~> 1.14)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@@ -170,7 +170,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (3.3.1)
bigdecimal (4.1.2)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -193,12 +193,12 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
concurrent-ruby (1.3.7)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
crass (1.0.7)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
@@ -211,13 +211,14 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
datadog (2.19.0)
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
libdatadog (~> 18.1.0.1.0)
libddwaf (~> 1.24.1.0.3)
datadog (2.38.0)
cgi
datadog-ruby_core_source (~> 3.5, >= 3.5.3)
libdatadog (~> 36.0.0.1.0)
libddwaf (~> 1.30.0.0.0)
logger
msgpack
datadog-ruby_core_source (3.4.1)
datadog-ruby_core_source (3.5.3)
date (3.5.1)
debug (1.8.0)
irb (>= 1.5.0)
@@ -274,8 +275,8 @@ GEM
dry-logic (~> 1.5)
dry-types (~> 1.8)
zeitwerk (~> 2.6)
dry-types (1.8.3)
bigdecimal (~> 3.0)
dry-types (1.9.1)
bigdecimal (>= 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
@@ -304,7 +305,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.2)
faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -474,7 +475,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.8)
json (2.19.9)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -522,15 +523,16 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
libdatadog (18.1.0.1.0)
libdatadog (18.1.0.1.0-x86_64-linux)
libddwaf (1.24.1.0.3)
libdatadog (36.0.0.1.0)
libdatadog (36.0.0.1.0-arm64-darwin)
libdatadog (36.0.0.1.0-x86_64-linux)
libddwaf (1.30.0.0.2)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-arm64-darwin)
libddwaf (1.30.0.0.2-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-darwin)
libddwaf (1.30.0.0.2-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-linux)
libddwaf (1.30.0.0.2-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
@@ -547,7 +549,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.23.1)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -570,7 +572,7 @@ GEM
minitest (5.25.5)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.8.0)
msgpack (1.8.3)
multi_json (1.15.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
@@ -582,7 +584,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.24)
net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
@@ -598,14 +600,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.5)
nokogiri (1.19.3)
nokogiri (1.19.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin)
nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-darwin)
nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.6)
auth-sanitizer (~> 0.2, >= 0.2.1)
@@ -627,7 +629,7 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oj (3.16.10)
oj (3.17.3)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.4)
@@ -674,7 +676,7 @@ GEM
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.1)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
@@ -740,8 +742,8 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
@@ -1030,7 +1032,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.7)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -1058,7 +1060,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.10.0)
ai-agents (>= 0.12.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2017-2024 Chatwoot Inc.
Copyright (c) 2017-2026 Chatwoot Inc.
Portions of this software are licensed as follows:
+1 -1
View File
@@ -1 +1 @@
4.14.2
4.16.0
+19 -3
View File
@@ -2,6 +2,14 @@
# It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction.
class AgentBuilder
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
class LimitExceededError < StandardError
def initialize
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
end
end
# Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
@@ -14,15 +22,23 @@ class AgentBuilder
# Creates a user and account user in a transaction.
# @return [User] the created user.
def perform
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
account.with_lock do
raise LimitExceededError unless can_add_agent?
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
end
@user
end
private
def can_add_agent?
account.usage_limits[:agents] > account.account_users.count
end
# Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user.
def find_or_create_user
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
def create_contact
account.contacts.create!(
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
name: contact_name,
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
@@ -59,6 +59,11 @@ class ContactInboxWithContactBuilder
)
end
def contact_name
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
end
def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
@@ -91,15 +91,17 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'],
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
external_url: attachment['url'] || attachment.dig('payload', 'url')
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Both `share` and `post` attachment types carry a page URL rather than a media file,
# so map them to `fallback` (which keeps the title/link without attempting a download).
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if type.to_sym == :share
return :fallback if [:share, :post].include?(type.to_sym)
super
end
@@ -6,6 +6,11 @@ class Messages::Messenger::MessageBuilder
return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment)
# During Meta's sticker webhook transition, a sticker message carries both an `image`
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
return if duplicate_sticker?(attachment, params[:external_url])
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save!
if facebook_reel?(attachment)
@@ -13,10 +18,14 @@ class Messages::Messenger::MessageBuilder
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
fetch_attachment_links(attachment_obj)
update_attachment_file_type(attachment_obj)
end
def fetch_attachment_links(attachment_obj)
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
def attach_file(attachment, file_url)
@@ -111,13 +120,20 @@ class Messages::Messenger::MessageBuilder
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
def duplicate_sticker?(attachment, url)
return false unless attachment['type'].to_sym == :sticker
return false if url.blank?
@message.attachments.any? { |existing| existing.external_url == url }
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
@@ -0,0 +1,213 @@
class V2::Reports::DrilldownBuilder
include DateRangeHelper
include TimezoneHelper
DEFAULT_GROUP_BY = 'day'.freeze
DEFAULT_PAGE = 1
DEFAULT_PER_PAGE = 25
MAX_PER_PAGE = 100
SUPPORTED_GROUP_BY = %w[hour day week month year].freeze
SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze
MESSAGE_METRICS = {
'incoming_messages_count' => :incoming,
'outgoing_messages_count' => :outgoing
}.freeze
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
pattr_initialize :account, :params
def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s)
def build
records = paginated_records.to_a
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
end
private
def meta
{
metric: metric,
record_type: record_type,
bucket: {
since: bucket_range.begin.to_i,
until: bucket_range.end.to_i
},
current_page: current_page,
per_page: per_page,
total_count: paginated_records.total_count,
conversation_count: conversation_count
}
end
def conversation_count
return paginated_records.total_count if conversation_metric?
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
end
def paginated_records
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
end
def drilldown_scope
if message_metric?
message_scope
elsif conversation_metric?
conversation_scope
else
reporting_event_scope
end
end
def message_scope
scope.messages
.where(account_id: account.id, created_at: bucket_range)
.public_send(MESSAGE_METRICS.fetch(metric))
.includes(:sender, conversation: [:assignee, :contact, :inbox])
.reorder(created_at: :desc)
end
def conversation_scope
scope.conversations
.where(account_id: account.id, created_at: bucket_range)
.includes(:assignee, :contact, :inbox)
.order(created_at: :desc)
end
def reporting_event_scope
events = scope.reporting_events
.where(account_id: account.id, name: raw_event_name, created_at: bucket_range)
.includes(:user, :inbox, conversation: [:assignee, :contact, :inbox])
.order(created_at: :desc)
if raw_count_strategy == :exclude_bot_handoffs
events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
elsif raw_count_strategy == :distinct_conversation
events = events.where(id: distinct_conversation_event_ids(events))
end
events
end
def bot_handoff_conversation_ids_subquery
scope.reporting_events
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
end
def distinct_conversation_event_ids(events)
events.reorder(nil)
.where.not(conversation_id: nil)
.select('MAX(reporting_events.id)')
.group(:conversation_id)
end
def record_serializer(records)
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(
account,
metric,
use_business_hours?,
records
)
end
def bucket_range
@bucket_range ||= begin
bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone)
bucket_end = bucket_end_for(bucket_start)
requested_start = Time.zone.at(params[:since].to_i)
requested_end = Time.zone.at(params[:until].to_i)
[bucket_start, requested_start].max...[bucket_end, requested_end].min
end
end
def bucket_end_for(bucket_start)
{
'hour' => bucket_start + 1.hour,
'day' => bucket_start + 1.day,
'week' => bucket_start + 1.week,
'month' => bucket_start + 1.month,
'year' => bucket_start + 1.year
}.fetch(group_by)
end
def scope
case dimension_type
when 'account' then account
when 'inbox' then inbox
when 'agent' then user
when 'label' then label
when 'team' then team
else
raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}"
end
end
def inbox = @inbox ||= account.inboxes.find(params[:id])
def user = @user ||= account.users.find(params[:id])
def label = @label ||= account.labels.find(params[:id])
def team = @team ||= account.teams.find(params[:id])
def metric
params[:metric].to_s
end
def report_metric
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
end
def raw_event_name
report_metric&.raw_event_name
end
def raw_count_strategy
report_metric&.raw_count_strategy
end
def record_type
return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric)
'conversation'
end
def message_metric?
MESSAGE_METRICS.key?(metric)
end
def conversation_metric?
metric == 'conversations_count'
end
def dimension_type
(params[:type].presence || 'account').to_s
end
def group_by
@group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
def current_page
[params[:page].to_i, DEFAULT_PAGE].max
end
def per_page
requested_per_page = params[:per_page].to_i
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
[requested_per_page, MAX_PER_PAGE].min
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
end
@@ -0,0 +1,199 @@
class V2::Reports::DrilldownRecordSerializer
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
attr_reader :account, :metric, :use_business_hours, :records
def initialize(account, metric, use_business_hours, records = [])
@account = account
@metric = metric
@use_business_hours = use_business_hours
@records = records
end
def serialize(record)
return serialize_message(record) if record.is_a?(Message)
return serialize_conversation_event(record) if record.is_a?(ReportingEvent)
serialize_conversation(record)
end
private
def serialize_message(message, metric_value: nil, occurred_at: nil)
{
record_type: 'message',
conversation: conversation_attributes(message.conversation),
message: message_attributes(message),
metric_value: metric_value,
occurred_at: (occurred_at || message.created_at).to_i
}
end
def serialize_conversation_event(event)
inferred_message = inferred_message_for(event)
if inferred_message.present?
return serialize_message(
inferred_message,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event)
)
end
serialize_conversation(
event.conversation,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event),
event_name: event.name
)
end
def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil)
serialized_record = {
record_type: 'conversation',
conversation: conversation_attributes(conversation),
message: nil,
metric_value: metric_value,
occurred_at: (occurred_at || conversation&.created_at)&.to_i
}
serialized_record[:event_name] = event_name if event_name.present?
serialized_record
end
def conversation_attributes(conversation)
return {} if conversation.blank?
{
id: conversation.id,
display_id: conversation.display_id,
contact_id: conversation.contact_id,
contact_name: conversation.contact&.name,
inbox_id: conversation.inbox_id,
inbox_name: conversation.inbox&.name,
assignee_id: conversation.assignee_id,
assignee_name: conversation.assignee&.name,
status: conversation.status,
created_at: conversation.created_at.to_i,
last_activity_at: conversation.last_activity_at.to_i,
last_message: last_message_attributes(conversation)
}
end
def message_attributes(message)
{
id: message.id,
content: message.content,
message_type: message.message_type,
sender_name: message.sender&.try(:name),
created_at: message.created_at.to_i
}
end
def last_message_attributes(conversation)
message = latest_messages_by_conversation_id[conversation.id]
return if message.blank?
message_attributes(message)
end
def inferred_message_for(event)
return unless MESSAGE_EVENT_METRICS.include?(metric)
return if event.conversation.blank? || event.event_end_time.blank?
inferred_messages_by_event_id[event.id]
end
def first_response_event_with_user?(event)
metric == 'avg_first_response_time' && event.user_id.present?
end
def message_inference_range(event)
(event.event_end_time - 1.second)..(event.event_end_time + 1.second)
end
def event_metric_value(event)
use_business_hours ? event.value_in_business_hours : event.value
end
def event_timestamp(event)
event.event_end_time || event.created_at
end
def latest_messages_by_conversation_id
@latest_messages_by_conversation_id ||= if conversation_ids.blank?
{}
else
latest_messages.index_by(&:conversation_id)
end
end
def latest_messages
Message
.where(account_id: account.id, conversation_id: conversation_ids)
.where.not(message_type: :activity)
.select('DISTINCT ON (messages.conversation_id) messages.*')
.reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC'))
.includes(:sender)
end
def inferred_messages_by_event_id
@inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id|
messages_by_event_id[event.id] = inferred_message_candidates.find do |message|
message_matches_event?(message, event)
end
end
end
def inferred_message_candidates
@inferred_message_candidates ||= if inference_events.blank?
[]
else
inferred_messages.to_a
end
end
def inferred_messages
Message
.where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq)
.where(created_at: inference_time_range)
.where(message_type: %i[outgoing template])
.includes(:sender)
.reorder(created_at: :desc, id: :desc)
end
def message_matches_event?(message, event)
message.conversation_id == event.conversation_id &&
message.created_at.between?(
message_inference_range(event).begin,
message_inference_range(event).end
) &&
message_sender_matches_event?(message, event)
end
def message_sender_matches_event?(message, event)
return true unless first_response_event_with_user?(event)
message.sender_id == event.user_id && message.sender_type == 'User'
end
def inference_time_range
event_end_times = inference_events.map(&:event_end_time)
(event_end_times.min - 1.second)..(event_end_times.max + 1.second)
end
def inference_events
@inference_events ||= records.select do |record|
record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present?
end
end
def conversation_ids
@conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq
end
def conversation_id_for(record)
return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent)
record.id
end
end
@@ -1,5 +1,4 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]
@@ -1,8 +1,6 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index, :bulk_create]
before_action :check_authorization
before_action :validate_limit, only: [:create]
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
def index
@agents = agents
@@ -20,6 +18,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
)
@agent = builder.perform
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
def update
@@ -36,25 +36,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def bulk_create
emails = params[:emails]
emails.each do |email|
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
begin
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
bulk_create_agents(emails)
# This endpoint is used to bulk create agents during onboarding
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
clear_onboarding_step
head :ok
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
private
@@ -87,22 +75,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end
def validate_limit_for_bulk_create
limit_available = params[:emails].count <= available_agent_count
def bulk_create_agents(emails)
Current.account.with_lock do
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
emails.each { |email| create_agent_from_email(email) }
end
end
def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
def create_agent_from_email(email)
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
def clear_onboarding_step
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
end
def available_agent_count
Current.account.usage_limits[:agents] - agents.count
end
def can_add_agent?
available_agent_count.positive?
Current.account.usage_limits[:agents] - Current.account.account_users.count
end
def delete_user_record(agent)
@@ -30,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def update
@article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
persist_article_changes if params[:article].present?
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
end
def destroy
@@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def reorder
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
render json: { positions: positions }
end
private
@@ -67,12 +67,26 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
# Draft-only autosaves must not bump the public-facing updated_at, so write
# them with update_columns (which skips the timestamp). update_columns also
# skips validations, so assign and validate first to avoid persisting content
# that exceeds the column length limit.
def persist_article_changes
keys = article_params.to_h.keys
if keys.any? && (keys - %w[draft_title draft_content]).empty?
@article.assign_attributes(article_params)
@article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
else
@article.update!(article_params)
end
end
def article_params
params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
:locale, meta: [:title,
:description,
{ tags: [] }]
:locale, :draft_title, :draft_content, meta: [:title,
:description,
{ tags: [] }]
)
end
@@ -2,6 +2,8 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes
def index
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
@include_agent_bots = params[:include_agent_bots].present?
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
@@ -10,6 +12,7 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end
private
@@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled
:fair_distribution_limit, :fair_distribution_window, :enabled,
:exclude_older_than_hours
)
end
end
@@ -2,5 +2,14 @@ class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale
private
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
end
@@ -0,0 +1,28 @@
class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
def show
set_branded_email_layout
end
def update
unless Current.account.feature_enabled?(:branded_email_templates)
render_could_not_create_error('Branded email templates feature is not enabled')
return
end
branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
set_branded_email_layout
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
private
def set_branded_email_layout
@branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
end
end
Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')
@@ -6,6 +6,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token]
page_id = params[:page_id]
inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token,
@@ -15,6 +16,8 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
end
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}"
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
@@ -8,8 +7,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
@current_account.save!
render json: preferences_payload
@@ -38,7 +37,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models)
existing_models.merge(permitted_captain_models).compact_blank.presence
end
def merged_captain_features
@@ -47,30 +46,38 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
end
def permitted_captain_models
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
end
def captain_feature_keys
Llm::Models.feature_keys.map(&:to_sym)
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
selected: account_models[feature_key] || config[:default]
model: route[:model],
selected: route[:model],
provider: route[:provider],
source: route[:source]
)
end
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
end
@@ -53,7 +53,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def category_params
params.require(:category).permit(
:name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
:name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id
)
end
@@ -6,6 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create
process_create
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -214,3 +214,5 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: error, status: error_status
end
end
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
@@ -1,6 +1,17 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include DeviseTokenAuth::Concerns::SetUserByToken
include RequestExceptionHandler
include AccessTokenAuthHelper
include EnsureCurrentAccountHelper
skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
around_action :handle_with_exception
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
before_action :authenticate_user!, unless: :authenticate_by_access_token?
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :conversation
def create
@@ -11,6 +22,16 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
private
def authenticate_by_access_token?
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
end
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
end
@@ -52,6 +52,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
render json: { content: translated_content }
rescue Google::Cloud::Error => e
# `details` carries the clean human message; `message` includes gRPC debug noise
render_could_not_create_error(e.details.presence || e.message)
end
private
@@ -1,27 +1,40 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
def show
@participants = @conversation.conversation_participants
end
def create
participant_ids_to_add = participants_to_be_added_ids
ActiveRecord::Base.transaction do
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
@participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
end
notify_unread_count_change if participant_ids_to_add.any?
end
def update
participant_ids_to_add = participants_to_be_added_ids
participant_ids_to_remove = participants_to_be_removed_ids
changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
ActiveRecord::Base.transaction do
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if changed_participant_ids.any?
@participants = @conversation.conversation_participants
render action: 'show'
end
def destroy
participant_ids_to_remove = current_participant_ids & params[:user_ids]
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if participant_ids_to_remove.any?
head :ok
end
@@ -38,4 +51,11 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun
def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end
def notify_unread_count_change
return unless Current.account.feature_enabled?('conversation_unread_counts')
return unless Current.account.feature_enabled?('unread_count_for_filters')
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
end
end
@@ -2,12 +2,28 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
counts = if filtered_unread_counts_enabled?
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
else
unread_counts
end
render json: { payload: counts }
end
private
def unread_counts
::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
end
def filtered_unread_counts_enabled?
Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
@@ -140,7 +140,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def destroy
authorize @conversation, :destroy?
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform
head :ok
end
@@ -164,6 +164,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
end
def should_update_last_seen?
@@ -1,4 +1,5 @@
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_dashboard_apps, except: [:create]
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
@@ -0,0 +1,177 @@
require 'csv'
class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController
DATA_IMPORT_FEATURE = 'data_import'.freeze
before_action :ensure_data_import_feature_enabled
before_action :set_data_import, only: [:show, :start, :retry_import, :abandon, :error_logs, :skip_logs]
before_action :check_authorization
def index
@data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc)
data_import_ids = @data_imports.map(&:id)
@import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
@skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
end
def show
render_show
end
def validate_source
totals = validate_intercom_source
render json: { valid: true, totals: totals }
rescue DataImports::Intercom::Client::AuthenticationError
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
rescue DataImports::Intercom::Client::Error
render_source_validation_error('Intercom could not be reached. Please try again.')
rescue ArgumentError => e
render_source_validation_error(e.message)
end
def create
@data_import = creation_service.perform
unless @data_import
render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity
return
end
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
render_show
rescue DataImports::Intercom::Client::AuthenticationError
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
rescue DataImports::Intercom::Client::Error
render_source_validation_error('Intercom could not be reached. Please try again.')
rescue ArgumentError => e
render_source_validation_error(e.message)
end
def start
restart_service = DataImports::Intercom::RestartService.new(account: Current.account, data_import: @data_import)
restart_result = restart_service.perform
@data_import = restart_service.data_import
if restart_result == :access_token_missing
render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
return
end
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) if restart_result == :enqueue
render_show
end
def retry_import
retry_service = DataImports::Intercom::RetryService.new(account: Current.account, data_import: @data_import)
retry_result = retry_service.perform
@data_import = retry_service.data_import
case retry_result
when :enqueue
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
render_show
when :not_stalled
render json: { message: 'This Intercom import is no longer stalled.' }, status: :unprocessable_entity
when :active_import_exists
render json: { message: 'Another Intercom import is already in progress.' }, status: :unprocessable_entity
when :access_token_missing
render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
end
end
def abandon
@data_import.abandon!
render_show
end
def skip_logs
send_data(
skip_logs_csv,
filename: "data-import-#{@data_import.id}-skip-logs.csv",
type: 'text/csv'
)
end
def error_logs
send_data(
error_logs_csv,
filename: "data-import-#{@data_import.id}-error-logs.csv",
type: 'text/csv'
)
end
private
def ensure_data_import_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE)
end
def set_data_import
@data_import = Current.account.data_imports.find(params[:id])
end
def check_authorization
authorize(@data_import || DataImport)
end
def permitted_params
params.permit(:name, :source_provider, :access_token, import_types: [])
end
def creation_service
DataImports::Intercom::CreationService.new(
account: Current.account,
initiated_by: Current.user,
source_params: permitted_params.to_h
)
end
def import_types
return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
Array(permitted_params[:import_types]).compact_blank
end
def validate_intercom_source
raise ArgumentError, 'Unsupported import source.' unless permitted_params[:source_provider] == 'intercom'
DataImports::Intercom::CredentialsValidator.new(
access_token: permitted_params[:access_token],
import_types: import_types
).perform
end
def render_source_validation_error(message)
render json: { valid: false, message: message }, status: :unprocessable_entity
end
def render_show
@import_errors_finder = DataImportErrorFinder.new(@data_import)
@skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params)
render :show
end
def skip_logs_csv
logs_csv(@data_import.import_errors.skip_logs)
end
def error_logs_csv
logs_csv(@data_import.import_errors.non_skip_logs)
end
def logs_csv(logs)
CSV.generate(headers: true) do |csv|
csv << %w[created_at kind source_object_type source_object_id error_code message details]
logs.order(:created_at).find_each do |log|
csv << [
log.created_at.iso8601,
log.details['kind'],
log.source_object_type,
log.source_object_id,
log.error_code,
log.message,
log.details.to_json
]
end
end
end
end
@@ -2,7 +2,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
@@ -46,11 +45,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def update
inbox_params = permitted_params.except(:channel, :csat_config)
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
continue_update = false
ActiveRecord::Base.transaction do
continue_update = update_branded_email_layout
raise ActiveRecord::Rollback unless continue_update
inbox_params = permitted_params.except(:channel, :csat_config)
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
end
return unless continue_update
end
def agent_bot
@@ -125,8 +133,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def reauthorize_and_update_channel(channel_attributes)
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
end
def update_channel_feature_flags
@@ -156,6 +164,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
formatted['template'] = config['template'] if config['template'].present?
end
def update_branded_email_layout
return true unless params.key?(:branded_email_layout)
branded_email_layout = normalized_branded_email_layout
unless Current.account.feature_enabled?(:branded_email_templates)
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email templates feature is not enabled')
return false
end
unless @inbox.email?
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email layout is only supported for email inboxes')
return false
end
@inbox.update_branded_email_layout!(branded_email_layout)
true
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
false
end
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
@@ -0,0 +1,9 @@
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
private
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
def check_authorization
authorize(:hook)
end
end
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end
render_response(
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
)
end
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_hook, except: [:create]
before_action :check_authorization
@@ -35,10 +35,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
@hook = Current.account.hooks.find(params[:id])
end
def check_authorization
authorize(:hook)
end
def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
end
@@ -1,6 +1,7 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
revoke_linear_token
@@ -1,5 +1,6 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
@hook.destroy!
@@ -1,7 +1,8 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
include Shopify::IntegrationHelper
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :check_authorization, only: [:destroy]
before_action :validate_contact, only: [:orders]
def auth
@@ -1,5 +1,4 @@
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :fetch_label, except: [:index, :create]
before_action :check_authorization
@@ -6,7 +6,10 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state
state: state,
# Force the Microsoft account picker so an already-signed-in account does not
# silently authorize and re-bind to an existing inbox in the new-inbox flow.
prompt: 'select_account'
}
)
if redirect_url
@@ -1,17 +1,19 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
ONBOARDING_STEP_KEY = 'onboarding_step'.freeze
STEP_ACCOUNT_DETAILS = 'account_details'.freeze
STEP_INBOX_SETUP = 'inbox_setup'.freeze
ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze
def update
return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step])
@account = Current.account
finalize = finalizing_account_details?
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
@account.custom_attributes.delete('onboarding_step') if finalize
@account.save!
# TODO: re-enable when the help center generation UI is ready to surface progress
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
# The client declares the step it is completing; `account_details` runs
# `complete_account_details`, and so on. The known-step guard above keeps the
# client value from `send`-ing an arbitrary method.
send("complete_#{params[:onboarding_step]}")
render 'api/v1/accounts/update', format: :json
end
@@ -22,12 +24,48 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
private
def finalizing_account_details?
@account.custom_attributes['onboarding_step'] == 'account_details'
def complete_account_details
# Only act while the cursor still points here, so a stale replay after
# onboarding finished can't re-enter it.
return unless current_step == STEP_ACCOUNT_DETAILS
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
# inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded
# environment check); self-hosted finishes onboarding here.
if ChatwootApp.chatwoot_cloud?
move_to_step(STEP_INBOX_SETUP)
create_onboarding_inboxes
else
finish_onboarding
end
end
def website
custom_attributes_params[:website]
def complete_inbox_setup
# Only finalize while the cursor still points here, so a stale or out-of-order
# request can't end onboarding early. Replays are no-ops.
return unless current_step == STEP_INBOX_SETUP
finish_onboarding
end
def current_step
@account.custom_attributes[ONBOARDING_STEP_KEY]
end
def move_to_step(step)
@account.custom_attributes[ONBOARDING_STEP_KEY] = step
@account.save!
end
def finish_onboarding
@account.custom_attributes.delete(ONBOARDING_STEP_KEY)
@account.save!
end
def create_onboarding_inboxes
Onboarding::WebWidgetCreationService.new(@account, Current.user).perform
end
def account_params
@@ -81,7 +81,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
)
end
@@ -89,6 +90,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def popular_content_keys
params.dig(:portal, :config, :popular_content)&.keys || []
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end
def team_params
params.require(:team).permit(:name, :description, :allow_auto_assign)
params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color)
end
end
@@ -1,4 +1,7 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :ensure_embedded_signup_enabled
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -8,12 +11,21 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue StandardError => e
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_embedded_signup_error(e)
end
private
def ensure_embedded_signup_enabled
return unless ChatwootApp.chatwoot_cloud?
return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
raise Pundit::NotAuthorizedError
end
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
@@ -29,7 +41,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
end
def validate_reauthorization_required
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
render json: {
success: false,
@@ -37,9 +49,11 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
def can_upgrade_to_embedded_signup?
def can_reconfigure_channel?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
return true if ChatwootApp.chatwoot_cloud?
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
end
@@ -55,7 +69,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
render json: response
end
def render_error_response(error)
def render_embedded_signup_error(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
@@ -8,6 +8,7 @@ class Api::V1::AccountsController < Api::BaseController
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
@@ -105,6 +106,12 @@ class Api::V1::AccountsController < Api::BaseController
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def validate_token_api_access
return if @account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end
@@ -0,0 +1,36 @@
class Api::V1::Profile::SessionsController < Api::BaseController
before_action :set_session, only: [:destroy]
def index
@sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc)
@current_client_id = request.headers['client']
end
def destroy
if @session.current?(request.headers['client'])
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
return
end
revoke_token!(@session.client_id)
@session.destroy!
head :ok
end
private
def set_session
@session = current_user.user_sessions.find(params[:id])
end
def revoke_token!(client_id)
tokens = current_user.tokens
tokens.delete(client_id)
current_user.update!(tokens: tokens)
end
def active_token_client_ids
now = Time.current.to_i
(current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys
end
end
@@ -59,6 +59,10 @@ class Api::V1::Widget::BaseController < ApplicationController
permitted_params.dig(:contact, :phone_number)
end
def contact_custom_attributes
permitted_params.dig(:contact, :custom_attributes)&.to_h
end
def browser_params
{
browser_name: browser.name,
@@ -2,6 +2,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
def show; end
@@ -46,6 +47,16 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end
# The plain update endpoint is also used for anonymous prechat updates
# (name/email/phone/custom_attributes with no identifier), which must keep
# working on hmac_mandatory inboxes. Only the identity-binding path, where an
# identifier is supplied and the contact can be rebound, requires HMAC.
def validate_hmac_for_identified_update
return if params[:identifier].blank?
validate_hmac
end
def validate_hmac
return unless should_verify_hmac?
@@ -62,11 +73,15 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
expected_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -19,7 +19,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def process_update_contact
@contact = ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes },
retain_original_contact_name: true,
discard_invalid_attrs: true
).perform
@@ -95,7 +95,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }],
message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end
@@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
response = dyte_processor_service.add_participant_to_meeting(
@message.content_attributes['data']['meeting_id'],
@conversation.contact
@conversation.contact,
@message
)
render_response(response)
end
@@ -51,6 +51,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
end
def drilldown
return head :unauthorized unless Current.account_user.administrator?
return head :unprocessable_entity unless valid_drilldown_params?
render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build
end
def conversations
return head :unprocessable_entity if params[:type].blank?
@@ -133,6 +140,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
})
end
def drilldown_params
permitted_params = params.permit(
:metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page
).to_h.symbolize_keys
permitted_params.merge(
type: (params[:type].presence || 'account').to_sym,
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
)
end
def valid_drilldown_params?
%i[metric bucket_timestamp since until].all? { |param| params[param].present? } &&
Reports::ReportMetricRegistry.supported?(params[:metric]) &&
V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params)
end
def conversation_params
{
type: params[:type].to_sym,
@@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base
include RequestExceptionHandler
include Pundit::Authorization
include SwitchLocale
include TrackSessionActivity
skip_before_action :verify_authenticity_token
@@ -14,6 +14,8 @@ module EnsureCurrentAccountHelper
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
else
render_unauthorized(I18n.t('errors.account.not_authorized'))
end
account
end
@@ -21,7 +23,7 @@ module EnsureCurrentAccountHelper
def account_accessible_for_user?(account)
@current_account_user = account.account_users.find_by(user_id: current_user.id)
Current.account_user = @current_account_user
render_unauthorized('You are not authorized to access this account') unless @current_account_user
render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user
end
def account_accessible_for_bot?(account)
@@ -0,0 +1,65 @@
module PortalHomeData
extend ActiveSupport::Concern
private
def load_home_data
load_recommended_content
# The classic hero only needs the recommendations above; the rest is
# documentation-layout home data (also used on custom-domain home pages).
return unless @portal.layout == 'documentation'
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @recommended_categories.presence || @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def load_recommended_content
@recommended_categories = recommended_categories
@recommended_articles = recommended_articles
end
def base_articles
@base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category)
end
# Admin-recommended categories for the locale, in the chosen order. Unlike the
# position-based fallback, published articles aren't required: the admin's pick wins.
def recommended_categories
ids = @portal.popular_category_ids(@locale)
ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids)
end
# Admin-recommended articles for the locale, in the chosen order, limited to
# published articles that still exist.
def recommended_articles
ids = @portal.popular_article_ids(@locale)
ordered_by_ids(base_articles.where(id: ids), ids)
end
# Loads the scope and returns its records ordered to match `ids`, dropping any
# that no longer exist. Skips the query entirely when `ids` is blank.
def ordered_by_ids(scope, ids)
return [] if ids.blank?
by_id = scope.index_by(&:id)
ids.filter_map { |id| by_id[id] }
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -1,8 +1,15 @@
module RequestExceptionHandler
extend ActiveSupport::Concern
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [
'ActiveRecord::QueryCanceled',
'PG::QueryCanceled',
'canceling statement due to statement timeout'
].freeze
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end
private
@@ -18,6 +25,9 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e
log_handled_error(e)
render_could_not_create_error(e.message)
rescue ActiveRecord::QueryCanceled => e
log_handled_error(e)
render_could_not_create_error(database_query_canceled_message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset
@@ -31,8 +41,8 @@ module RequestExceptionHandler
render json: { error: message }, status: :not_found
end
def render_could_not_create_error(message)
render json: { error: message }, status: :unprocessable_entity
def render_could_not_create_error(error)
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
end
def render_payment_required(message)
@@ -59,4 +69,19 @@ module RequestExceptionHandler
def log_handled_error(exception)
logger.info("Handled error: #{exception.inspect}")
end
def sanitized_error_message(message)
return database_query_canceled_message if database_query_canceled_message?(message)
message
end
def database_query_canceled_message?(message)
error_message = message.to_s
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) }
end
def database_query_canceled_message
I18n.t('errors.database.query_canceled')
end
end
@@ -0,0 +1,22 @@
module TrackSessionActivity
extend ActiveSupport::Concern
included do
after_action :update_session_activity
end
private
def update_session_activity
return unless current_user
return if request.headers['client'].blank?
UserSessionTrackingService.new(
user: current_user,
request: request,
client_id: request.headers['client']
).update_activity!
rescue StandardError => e
Rails.logger.warn "Session activity update failed: #{e.message}"
end
end
+3
View File
@@ -1,5 +1,6 @@
class DashboardController < ActionController::Base
include SwitchLocale
include PortalHomeData
GLOBAL_CONFIG_KEYS = %w[
LOGO
@@ -63,6 +64,8 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
request.variant = :documentation if @portal.layout == 'documentation'
load_home_data
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
@@ -1,4 +1,6 @@
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
MAX_SESSIONS = ENV.fetch('MAX_USER_SESSIONS', 25).to_i
# Prevent session parameter from being passed
# Unpermitted parameter: session
wrap_parameters format: []
@@ -14,12 +16,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = find_user_for_authentication
return handle_mfa_required(user) if user&.mfa_enabled?
return if user && enforce_session_limit_for_password_login(user)
# Only proceed with standard authentication if no MFA is required
super
end
def render_create_success
track_user_session unless @impersonation
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end
@@ -53,6 +57,8 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def handle_sso_authentication
return if !@impersonation && enforce_session_limit_for_password_login(@resource)
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
@@ -65,7 +71,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def authenticate_resource_with_sso_token
@token = @resource.create_token
# DTA evicts the earliest-expiring token after save when at max_number_of_devices.
# The short-lived impersonation token would always be that one, so pre-evict to make room.
make_room_for_impersonation_token if @impersonation
@token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil)
@resource.save!
sign_in(:user, @resource, store: false, bypass: false)
@@ -73,11 +82,21 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
@resource.invalidate_sso_auth_token(params[:sso_auth_token])
end
def make_room_for_impersonation_token
return if @resource.tokens.size < DeviseTokenAuth.max_number_of_devices
oldest_client_id = @resource.tokens.min_by { |_, v| v['expiry'].to_i }&.first
@resource.tokens.delete(oldest_client_id) if oldest_client_id
end
def process_sso_auth_token
return if params[:email].blank?
user = User.from_email(params[:email])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
return unless user&.valid_sso_auth_token?(params[:sso_auth_token])
@resource = user
@impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token])
end
def handle_mfa_required(user)
@@ -103,6 +122,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def sign_in_mfa_user(user)
evict_oldest_session(user) if sessions_limit_reached?(user)
@resource = user
@token = @resource.create_token
@resource.save!
@@ -114,6 +134,103 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status
end
def sessions_limit_reached?(user)
active_token_count(user) >= MAX_SESSIONS
end
def active_token_count(user)
now = Time.current.to_i
(user.tokens || {}).count { |_, v| v['expiry'].to_i > now }
end
# Returns true when a response has been rendered (e.g., 409 picker). Non-browser clients
# auto-evict instead of getting stuck on a UI they can't render.
def enforce_session_limit_for_password_login(user)
if revoking_sessions?
revoke_sessions_for_login(user)
return false
end
return false unless sessions_limit_reached?(user)
# Picker only when every token has a tracked session; partial tracking would
# show a misleading count, so fall through to silent eviction instead.
if browser_request? && user.user_sessions.count >= user.tokens.size
handle_sessions_limit_for_login(user)
true
else
evict_oldest_session(user)
false
end
end
def browser_request?
request.user_agent.to_s.include?('Mozilla')
end
def revoking_sessions?
params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
end
def revoke_sessions_for_login(user)
if params[:revoke_all_sessions].present?
user.tokens = {}
user.save!
user.user_sessions.destroy_all
elsif params[:revoke_session_id].present?
session = user.user_sessions.find_by(id: params[:revoke_session_id])
return unless session
user.tokens.delete(session.client_id)
user.save!
session.destroy!
end
end
def evict_oldest_session(user)
# Drop pre-rollout untracked tokens first so freshly tracked logins aren't evicted.
return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size
oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
return evict_oldest_token(user) unless oldest_session
user.tokens.delete(oldest_session.client_id)
user.save!
oldest_session.destroy!
end
# Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
def evict_oldest_token(user)
return if user.tokens.blank?
oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
return unless oldest_client_id
user.tokens.delete(oldest_client_id)
user.save!
end
PICKER_SESSION_FIELDS = %i[id browser_name browser_version device_name platform_name platform_version
ip_address city country last_activity_at created_at].freeze
def handle_sessions_limit_for_login(user)
sessions = user.user_sessions.order(last_activity_at: :desc).map { |s| s.slice(*PICKER_SESSION_FIELDS) }
render json: { sessions_limit_reached: true, sessions: sessions }, status: :conflict
end
def track_user_session
client_id = @token&.try(:client) || response.headers['client']
return unless client_id.present? && @resource.present?
UserSessionTrackingService.new(
user: @resource,
request: request,
client_id: client_id
).create_or_update!
rescue StandardError => e
Rails.logger.warn "Session tracking failed: #{e.message}"
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
@@ -11,6 +11,8 @@ class Instagram::CallbacksController < ApplicationController
end
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -47,6 +49,14 @@ class Instagram::CallbacksController < ApplicationController
redirect_to_error_page(error_info)
end
def handle_limit_error(error)
redirect_to_error_page(
'error_type' => error.class.name,
'code' => Rack::Utils.status_code(error.http_status),
'error_message' => error.message
)
end
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
@@ -18,7 +18,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
contact: @contact_inbox.contact,
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
)
render json: contact_identify_action.perform
contact_identify_action.perform
@contact_inbox.reload
end
private
@@ -35,11 +36,15 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
expected_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@inbox_channel.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -24,7 +24,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def set_view_variant
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
request.variant = if @is_plain_layout_enabled
:plain
elsif @portal_layout == 'documentation'
:documentation
end
end
def portal
@@ -39,9 +43,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def switch_locale_with_portal(&)
@locale = validate_and_get_locale(params[:locale])
# Keep @locale as the portal's own locale code (e.g. th_TH) for content queries,
# while UI translations fall back to an available I18n locale (e.g. th).
@locale = params[:locale]
I18n.with_locale(@locale, &)
I18n.with_locale(validate_and_get_locale(@locale), &)
end
def switch_locale_with_article(&)
@@ -49,13 +55,12 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
article_locale = if article.category.present?
article.category.locale
else
article.locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
@locale = if article.category.present?
article.category.locale
else
article.locale
end
I18n.with_locale(validate_and_get_locale(@locale), &)
end
def allow_iframe_requests
@@ -64,6 +69,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
def render_404
portal
# set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less
set_view_variant
render 'public/api/v1/portals/error/404', status: :not_found
end
@@ -1,11 +1,13 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
include PortalHomeData
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled }
layout 'portal'
def show
@@ -31,28 +33,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -35,7 +35,8 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
#
def resource_params
permitted_params = super
permitted_params[:limits] = permitted_params[:limits].to_h.compact
permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits)
permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models)
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
permitted_params
end
@@ -6,6 +6,8 @@ class Tiktok::CallbacksController < ApplicationController
return handle_ungranted_scopes_error unless all_scopes_granted?
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -36,6 +38,14 @@ class Tiktok::CallbacksController < ApplicationController
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
end
def handle_limit_error(error)
redirect_to_error_page(
error_type: error.class.name,
code: Rack::Utils.status_code(error.http_status),
error_message: error.message
)
end
# Handles the case when a user denies permissions or cancels the authorization flow
def handle_authorization_error
redirect_to_error_page(
+11 -1
View File
@@ -35,7 +35,17 @@ class Twilio::CallbackController < ApplicationController
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username
:Username,
:ReferralBody,
:ReferralHeadline,
:ReferralSourceId,
:ReferralSourceType,
:ReferralSourceUrl,
:ReferralMediaId,
:ReferralMediaContentType,
:ReferralMediaUrl,
:ReferralNumMedia,
:ReferralCtwaClid
)
end
end
@@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank?
phone_number = normalized_phone_number(metadata[:display_phone_number])
phone_number_id = metadata[:phone_number_id]
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
def normalized_phone_number(phone_number)
return if phone_number.blank?
phone_number = phone_number.to_s
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
Whatsapp::WebhookChannelFinderService.new(
display_phone_number: metadata[:display_phone_number],
phone_number_id: metadata[:phone_number_id]
).perform
end
def inactive_whatsapp_number?
+4 -1
View File
@@ -18,6 +18,7 @@ class AccountDashboard < Administrate::BaseDashboard
# Add all_features last so it appears after manually_managed_features
attributes[:all_features] = AccountFeaturesField
attributes[:captain_models] = CaptainModelOverridesField
attributes
else
@@ -57,6 +58,7 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[custom_attributes limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -79,6 +81,7 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -117,7 +120,7 @@ class AccountDashboard < Administrate::BaseDashboard
# to prevent an error from being raised (wrong number of arguments)
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
def permitted_attributes(action)
attrs = super + [limits: {}]
attrs = super + [limits: {}, captain_models: {}]
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
+4
View File
@@ -1,6 +1,10 @@
class ConversationDrop < BaseDrop
include MessageFormatHelper
def id
@obj.try(:display_id)
end
def display_id
@obj.try(:display_id)
end
+14
View File
@@ -2,4 +2,18 @@ class InboxDrop < BaseDrop
def name
@obj.try(:name)
end
def business_name
@obj.try(:sanitized_business_name)
end
def avatar_url
@obj.try(:avatar_url)
end
def email
return unless @obj.try(:email?)
@obj.try(:email_address).presence || @obj.try(:channel).try(:email)
end
end
+13 -1
View File
@@ -12,6 +12,7 @@ class ConversationFinder
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
'unread' => %w[sort_on_unread desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
@@ -40,7 +41,7 @@ class ConversationFinder
def perform
set_up
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
mine_count, unassigned_count, all_count = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
filter_by_assignee_type
@@ -184,6 +185,17 @@ class ConversationFinder
end
def set_count_for_all_conversations
return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading?
counts = @conversations.unscope(:order).pick(
Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"),
Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'),
Arel.sql('COUNT(*)')
)
counts || [0, 0, 0]
end
def legacy_count_for_all_conversations
[
@conversations.assigned_to(current_user).count,
@conversations.unassigned.count,
+11
View File
@@ -0,0 +1,11 @@
class DataImportErrorFinder
RESULTS_LIMIT = 5
def initialize(data_import)
@data_import = data_import
end
def import_errors
@data_import.import_errors.non_skip_logs.order(created_at: :desc).limit(RESULTS_LIMIT)
end
end
@@ -0,0 +1,35 @@
class DataImportSkipLogFinder
RESULTS_LIMIT = 5
SOURCE_OBJECT_TYPES = %w[contact conversation message].freeze
attr_reader :selected_source_object_type
def initialize(data_import, params = {})
@data_import = data_import
@selected_source_object_type = valid_source_object_type(params[:skip_logs_type])
end
def skip_logs
filtered_scope.order(created_at: :desc).limit(RESULTS_LIMIT)
end
def counts_by_type
base_scope.group(:source_object_type).count
end
private
def base_scope
@base_scope ||= @data_import.import_errors.skip_logs
end
def filtered_scope
return base_scope if selected_source_object_type.blank?
base_scope.where(source_object_type: selected_source_object_type)
end
def valid_source_object_type(source_object_type)
source_object_type if SOURCE_OBJECT_TYPES.include?(source_object_type)
end
end
-6
View File
@@ -114,10 +114,4 @@ module Api::V1::InboxesHelper
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
def validate_limit
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
end
end
+12
View File
@@ -22,4 +22,16 @@ module BillingHelper
def agents(account)
account.users.count
end
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
def subscription_period_end(subscription)
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
end
def subscription_ends_on(subscription)
period_end = subscription_period_end(subscription)
return if period_end.blank?
Time.zone.at(period_end)
end
end
+10
View File
@@ -68,6 +68,8 @@ module Filters::FilterHelper
when 'text_case_insensitive'
text_case_insensitive_filter(query_hash, filter_operator_value)
else
return text_cast_filter(query_hash, filter_operator_value) if text_search_on_display_id?(query_hash)
default_filter(query_hash, filter_operator_value)
end
end
@@ -82,10 +84,18 @@ module Filters::FilterHelper
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_cast_filter(query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::text #{filter_operator_value} #{query_hash[:query_operator]}"
end
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_search_on_display_id?(query_hash)
query_hash[:attribute_key] == 'display_id' && %w[contains does_not_contain].include?(query_hash[:filter_operator])
end
def validate_single_condition(condition)
return if condition['query_operator'].nil?
return if condition['query_operator'].empty?
+17 -5
View File
@@ -17,15 +17,11 @@ module PortalHelper
uri.to_s
end
def generate_portal_bg_color(portal_color, theme)
def generate_portal_bg(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
end
def generate_portal_bg(portal_color, theme)
generate_portal_bg_color(portal_color, theme)
end
def generate_gradient_to_bottom(theme)
base_color = theme == 'dark' ? '#151718' : 'white'
"linear-gradient(to bottom, transparent, #{base_color})"
@@ -41,6 +37,10 @@ module PortalHelper
language_map[locale] || locale
end
def html_lang_attribute(locale)
locale.to_s.tr('_', '-')
end
def theme_query_string(theme)
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
@@ -97,6 +97,18 @@ module PortalHelper
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
# Renders a stored category icon: a bare ri icon name (e.g. `vip-crown-2-fill/line`) saved color, or a plain emoji character.
def render_emoji_or_icon(value, color = nil)
return '' if value.blank?
# Emojis are non-ascii; bare icon names match this safe charset.
return ERB::Util.html_escape(value) unless value.match?(/\A[a-z][a-z0-9-]*\z/)
icon_class = value.start_with?('i-') ? value : "i-ri-#{value}"
style = "color: #{color};" if color.to_s.match?(/\A#\h{3,8}\z/)
tag.span(class: icon_class, style: style, 'aria-hidden': true)
end
def thumbnail_bg_color(username)
colors = ['#6D95BA', '#A4C3C3', '#E19191']
return colors.sample if username.blank?
@@ -6,9 +6,12 @@ class AssignableAgents extends ApiClient {
super('assignable_agents', { accountScoped: true });
}
get(inboxIds) {
get(inboxIds, { includeAgentBots = false } = {}) {
return axios.get(this.url, {
params: { inbox_ids: inboxIds },
params: {
inbox_ids: inboxIds,
...(includeAgentBots ? { include_agent_bots: true } : {}),
},
});
}
}
+6
View File
@@ -106,4 +106,10 @@ export default {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
getSessions() {
return axios.get('/api/v1/profile/sessions');
},
revokeSession(id) {
return axios.delete(`/api/v1/profile/sessions/${id}`);
},
};
+14
View File
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class CallsAPI extends ApiClient {
constructor() {
super('calls', { accountScoped: true });
}
get(params = {}) {
return axios.get(this.url, { params });
}
}
export default new CallsAPI();
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class CaptainAgentSessions extends ApiClient {
constructor() {
super('captain/agent_sessions', { accountScoped: true });
}
}
export default new CaptainAgentSessions();
@@ -1,6 +1,10 @@
/* global axios */
import ApiClient from '../ApiClient';
// Viewer's UTC offset in hours, matching the reports API convention so the
// backend can anchor calendar ranges to the viewer's day.
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
@@ -21,6 +25,42 @@ class CaptainAssistant extends ApiClient {
message_history: messageHistory,
});
}
getMetrics({ assistantId, range, signal }) {
const requestConfig = {
params: { range, timezone_offset: getTimezoneOffset() },
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
}
getFaqStats({ assistantId, signal }) {
const requestConfig = {};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
}
getSummary({ assistantId, range, stats }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range, timezone_offset: getTimezoneOffset(), stats },
});
}
getDrilldown({ assistantId, metric, range, page, signal }) {
const requestConfig = {
params: {
metric,
range,
timezone_offset: getTimezoneOffset(),
page,
},
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/drilldown`, requestConfig);
}
}
export default new CaptainAssistant();
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class MessageReports extends ApiClient {
constructor() {
super('captain/message_reports', { accountScoped: true });
}
}
export default new MessageReports();
@@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
initiate(conversationId, sdpOffer) {
// Either conversationId, or contactId + inboxId to let the BE resolve the conversation.
initiate({ conversationId, contactId, inboxId }, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
contact_id: contactId,
inbox_id: inboxId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
+6
View File
@@ -40,6 +40,12 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/conversations`, { params });
}
getAttachments(contactId, page = 1) {
return axios.get(`${this.url}/${contactId}/attachments`, {
params: { page },
});
}
getContactableInboxes(contactId) {
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
}
@@ -0,0 +1,43 @@
/* global axios */
import ApiClient from './ApiClient';
class DataImportsAPI extends ApiClient {
constructor() {
super('data_imports', { accountScoped: true });
}
start(id) {
return axios.post(`${this.url}/${id}/start`);
}
retry(id) {
return axios.post(`${this.url}/${id}/retry`);
}
abandon(id) {
return axios.post(`${this.url}/${id}/abandon`);
}
show(id, params = {}) {
return axios.get(`${this.url}/${id}`, { params });
}
validateSource(payload) {
return axios.post(`${this.url}/validate_source`, payload);
}
downloadSkipLogs(id) {
return axios.get(`${this.url}/${id}/skip_logs.csv`, {
responseType: 'blob',
});
}
downloadErrorLogs(id) {
return axios.get(`${this.url}/${id}/error_logs.csv`, {
responseType: 'blob',
});
}
}
export default new DataImportsAPI();
@@ -14,6 +14,10 @@ class EnterpriseAccountAPI extends ApiClient {
return axios.post(`${this.url}subscription`);
}
selectBillingCurrency(currency) {
return axios.post(`${this.url}select_billing_currency`, { currency });
}
getLimits() {
return axios.get(`${this.url}limits`);
}
@@ -27,6 +31,11 @@ class EnterpriseAccountAPI extends ApiClient {
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
// Topup packages for the account's billing currency.
getTopupOptions() {
return axios.get(`${this.url}topup_options`);
}
}
export default new EnterpriseAccountAPI();
@@ -16,6 +16,8 @@ class ArticlesAPI extends PortalsAPI {
authorId,
categorySlug,
sort,
query,
signal,
}) {
const url = getArticleSearchURL({
pageNumber,
@@ -25,10 +27,11 @@ class ArticlesAPI extends PortalsAPI {
authorId,
categorySlug,
sort,
query,
host: this.url,
});
return axios.get(url);
return axios.get(url, { signal });
}
searchArticles({ portalSlug, query }) {
@@ -62,9 +62,10 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId }) {
assignAgent({ conversationId, agentId, assigneeType }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
+6
View File
@@ -60,6 +60,12 @@ class Inboxes extends CacheEnabledApiClient {
disableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
}
setInboundCalls(inboxId, enabled) {
return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, {
inbound_calls_enabled: enabled,
});
}
}
export default new Inboxes();
+36
View File
@@ -31,6 +31,42 @@ class ReportsAPI extends ApiClient {
});
}
getDrilldown({
metric,
bucketTimestamp,
from,
to,
type = 'account',
id,
groupBy,
businessHours,
page,
perPage,
signal,
}) {
const requestConfig = {
params: {
metric,
bucket_timestamp: bucketTimestamp,
since: from,
until: to,
type,
id,
group_by: groupBy,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
page,
per_page: perPage,
},
};
if (signal) {
requestConfig.signal = signal;
}
return axios.get(`${this.url}/drilldown`, requestConfig);
}
// eslint-disable-next-line default-param-last
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
return axios.get(`${this.url}/summary`, {
@@ -37,7 +37,8 @@ describe('#PortalAPI', () => {
authorId: '1',
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1',
{ signal: undefined }
);
});
});
@@ -26,5 +26,15 @@ describe('#AssignableAgentsAPI', () => {
},
});
});
it('#getAssignableAgents with agent bots', () => {
assignableAgentsAPI.get([1], { includeAgentBots: true });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
include_agent_bots: true,
},
});
});
});
});
@@ -90,11 +90,16 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
@@ -1,6 +1,8 @@
import reportsAPI from '../reports';
import ApiClient from '../ApiClient';
const timezoneOffset = () => -new Date().getTimezoneOffset() / 60;
describe('#Reports API', () => {
it('creates correct instance', () => {
expect(reportsAPI).toBeInstanceOf(ApiClient);
@@ -11,6 +13,7 @@ describe('#Reports API', () => {
expect(reportsAPI).toHaveProperty('update');
expect(reportsAPI).toHaveProperty('delete');
expect(reportsAPI).toHaveProperty('getReports');
expect(reportsAPI).toHaveProperty('getDrilldown');
expect(reportsAPI).toHaveProperty('getSummary');
expect(reportsAPI).toHaveProperty('getAgentReports');
expect(reportsAPI).toHaveProperty('getLabelReports');
@@ -42,11 +45,14 @@ describe('#Reports API', () => {
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', {
params: {
business_hours: undefined,
group_by: undefined,
id: undefined,
metric: 'conversations_count',
since: 1621103400,
until: 1621621800,
type: 'account',
timezone_offset: -0,
timezone_offset: timezoneOffset(),
},
});
});
@@ -59,13 +65,70 @@ describe('#Reports API', () => {
group_by: undefined,
id: undefined,
since: 1621103400,
timezone_offset: -0,
timezone_offset: timezoneOffset(),
type: 'account',
until: 1621621800,
},
});
});
it('#getDrilldown', () => {
reportsAPI.getDrilldown({
metric: 'incoming_messages_count',
bucketTimestamp: 1621103400,
from: 1621103400,
to: 1621621800,
type: 'inbox',
id: 1,
groupBy: 'day',
businessHours: false,
page: 2,
perPage: 25,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
params: {
metric: 'incoming_messages_count',
bucket_timestamp: 1621103400,
since: 1621103400,
until: 1621621800,
type: 'inbox',
id: 1,
group_by: 'day',
business_hours: false,
timezone_offset: timezoneOffset(),
page: 2,
per_page: 25,
},
});
});
it('#getDrilldown with abort signal', () => {
const controller = new AbortController();
reportsAPI.getDrilldown({
metric: 'incoming_messages_count',
bucketTimestamp: 1621103400,
signal: controller.signal,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
params: {
metric: 'incoming_messages_count',
bucket_timestamp: 1621103400,
since: undefined,
until: undefined,
type: 'account',
id: undefined,
group_by: undefined,
business_hours: undefined,
timezone_offset: timezoneOffset(),
page: undefined,
per_page: undefined,
},
signal: controller.signal,
});
});
it('#getAgentReports', () => {
reportsAPI.getAgentReports({
from: 1621103400,
@@ -145,6 +145,12 @@
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
// Voice call widget - light mode
--call-widget: 33, 34, 38, 0.95;
--call-widget-border: 255, 255, 255, 0.1;
--call-widget-text: 237, 238, 240, 1;
--call-widget-sub-text: 173, 177, 184, 1;
}
.dark {
@@ -291,6 +297,12 @@
--border-blue: 39, 129, 246, 0.5;
--border-container: 255, 255, 255, 0;
--white-alpha: 255, 255, 255, 0.1;
// Voice call widget - dark mode
--call-widget: 50, 53, 61, 1;
--call-widget-border: 255, 255, 255, 0.07;
--call-widget-text: 237, 238, 240, 1;
--call-widget-sub-text: 173, 177, 184, 1;
}
}
// NEXT COLORS END
@@ -0,0 +1,240 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
import {
VOICE_CALL_DIRECTION,
VOICE_CALL_STATUS,
} from 'dashboard/components-next/message/constants';
import CallStatusBadge from './CallStatusBadge.vue';
import { CALL_KIND, getCallKind } from './constants';
const props = defineProps({
call: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const route = useRoute();
const kind = computed(() => getCallKind(props.call));
const contactName = computed(() =>
(props.call.contact.name || props.call.contact.phoneNumber || '').replace(
/^\+/,
''
)
);
const agentActionLabel = computed(() => {
if (!props.call.agent) return '';
if (kind.value === CALL_KIND.OUTGOING) return t('CALLS_PAGE.ROW.DIALED_BY');
if (kind.value === CALL_KIND.INCOMING) return t('CALLS_PAGE.ROW.PICKED_BY');
// Ongoing collapses direction, so resolve dialed-vs-picked from the raw value.
if (kind.value === CALL_KIND.ONGOING) {
return props.call.direction === VOICE_CALL_DIRECTION.OUTBOUND
? t('CALLS_PAGE.ROW.DIALED_BY')
: t('CALLS_PAGE.ROW.PICKED_BY');
}
return '';
});
const resultLabel = computed(() => {
if (kind.value === CALL_KIND.MISSED) return t('CALLS_PAGE.ROW.NO_AGENT');
if (kind.value === CALL_KIND.NO_REPLY) {
return t('CALLS_PAGE.ROW.NO_CONTACT_ANSWER');
}
if (kind.value === CALL_KIND.FAILED) return t('CALLS_PAGE.ROW.FAILED');
if (kind.value === CALL_KIND.ONGOING) {
return props.call.status === VOICE_CALL_STATUS.RINGING
? t('CALLS_PAGE.ROW.RINGING')
: t('CALLS_PAGE.ROW.IN_PROGRESS');
}
return t('CALLS_PAGE.ROW.ANSWERED');
});
const providerIcon = computed(() =>
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
);
const createdAtLabel = computed(() =>
relativeDayTimestamp(props.call.createdAt, t('CALLS_PAGE.ROW.YESTERDAY'))
);
const conversationRoute = computed(() => ({
name: 'inbox_conversation',
params: {
accountId: route.params.accountId,
conversation_id: props.call.conversation.displayId,
},
query: { messageId: props.call.messageId },
}));
</script>
<template>
<div class="flex flex-col gap-2 py-3.5 border-b border-n-weak lg:hidden">
<div class="flex items-center gap-2 min-w-0">
<Avatar
:src="call.contact.avatar"
:name="contactName"
:size="24"
rounded-full
/>
<span
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
class="text-heading-3 font-medium truncate text-n-slate-12 min-w-0"
>
{{ contactName }}
</span>
<CallStatusBadge :kind="kind" class="ms-auto shrink-0" />
<RouterLink
:to="conversationRoute"
class="inline-flex items-center h-6 gap-1 px-2 text-label-small outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0"
>
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
{{ call.conversation.displayId }}
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
</RouterLink>
</div>
<div class="flex items-center gap-1.5 min-w-0">
<template v-if="agentActionLabel">
<span class="text-label-small text-n-slate-10 shrink-0">
{{ agentActionLabel }}
</span>
<Avatar
:src="call.agent.avatar"
:name="call.agent.name"
:size="20"
rounded-full
/>
<span class="text-body-main truncate text-n-slate-12 min-w-0">
{{ call.agent.name }}
</span>
</template>
<span v-else class="text-body-main truncate text-n-slate-10 min-w-0">
{{ resultLabel }}
</span>
<span class="w-px h-3 bg-n-strong shrink-0" />
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11 min-w-0">
{{ call.inbox.name }}
</span>
<span
v-if="!call.recordingUrl"
class="ms-auto shrink-0 text-label-small text-n-slate-11 tabular-nums"
>
{{ createdAtLabel }}
</span>
</div>
<div
v-if="call.recordingUrl"
class="flex items-center gap-2 min-w-0 justify-between"
>
<AudioPlayer
:src="call.recordingUrl"
:fallback-duration="call.durationSeconds || 0"
class="flex-1 sm:flex-[0.7] min-w-0"
/>
<span class="shrink-0 text-label-small text-n-slate-11 tabular-nums">
{{ createdAtLabel }}
</span>
</div>
</div>
<div
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
>
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
<Avatar
:src="call.contact.avatar"
:name="contactName"
:size="24"
rounded-full
/>
<span
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
class="text-heading-3 font-medium truncate text-n-slate-12"
>
{{ contactName }}
</span>
</div>
<div
class="flex flex-nowrap items-center gap-x-2 gap-y-2 min-w-0 grow shrink"
>
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
<CallStatusBadge :kind="kind" class="shrink-0" />
<template v-if="agentActionLabel">
<span
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
>
{{ agentActionLabel }}
</span>
<span class="flex items-center gap-1.5 min-w-16 shrink-[20]">
<Avatar
:src="call.agent.avatar"
:name="call.agent.name"
:size="20"
rounded-full
/>
<span
v-tooltip.top="{
content: call.agent.name,
delay: { show: 500, hide: 0 },
}"
class="text-body-main truncate text-n-slate-12 min-w-0"
>
{{ call.agent.name }}
</span>
</span>
</template>
<span
v-else-if="resultLabel"
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
>
{{ resultLabel }}
</span>
</div>
<AudioPlayer
v-if="call.recordingUrl"
:src="call.recordingUrl"
:fallback-duration="call.durationSeconds || 0"
class="w-auto min-w-44 shrink mx-auto"
/>
</div>
<div
v-tooltip.top="{
content: call.inbox.name,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
>
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11">
{{ call.inbox.name }}
</span>
</div>
<RouterLink
:to="conversationRoute"
class="inline-flex items-center h-6 gap-1 px-2 text-label-small py-3.5 outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0 justify-self-start"
>
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
{{ call.conversation.displayId }}
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
</RouterLink>
<span
v-tooltip.top="{
content: createdAtLabel,
delay: { show: 500, hide: 0 },
}"
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
>
{{ createdAtLabel }}
</span>
</div>
</template>
@@ -0,0 +1,158 @@
<script setup>
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
import { downloadFile } from '@chatwoot/utils';
import { useEmitter } from 'dashboard/composables/emitter';
import { emitter } from 'shared/helpers/mitt';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
src: {
type: String,
required: true,
},
fallbackDuration: {
type: Number,
default: 0,
},
});
const PLAYBACK_SPEEDS = [1, 1.5, 2];
const audioPlayer = useTemplateRef('audioPlayer');
const { uid } = getCurrentInstance();
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(props.fallbackDuration);
const playbackSpeed = ref(1);
const onLoadedMetadata = () => {
const loadedDuration = audioPlayer.value?.duration;
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
};
const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
const displayedTime = computed(() =>
formatTime(
isPlaying.value || currentTime.value ? currentTime.value : duration.value
)
);
// Only one recording should play at a time across the list.
useEmitter('pause_playing_audio', currentPlayingId => {
if (currentPlayingId !== uid && isPlaying.value) {
audioPlayer.value?.pause();
isPlaying.value = false;
}
});
const playOrPause = () => {
if (isPlaying.value) {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
emitter.emit('pause_playing_audio', uid);
audioPlayer.value.play();
isPlaying.value = true;
}
};
const onTimeUpdate = () => {
currentTime.value = audioPlayer.value?.currentTime;
};
const seek = event => {
const time = Number(event.target.value);
audioPlayer.value.currentTime = time;
currentTime.value = time;
};
const onEnd = () => {
isPlaying.value = false;
currentTime.value = 0;
};
const changePlaybackSpeed = () => {
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
playbackSpeed.value =
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
audioPlayer.value.playbackRate = playbackSpeed.value;
};
const downloadRecording = () => {
downloadFile({ url: props.src, type: 'audio' });
};
</script>
<template>
<div
class="flex items-center justify-center h-9 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
@click.stop
>
<audio
ref="audioPlayer"
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="src" />
</audio>
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-12"
@click="playOrPause"
>
<template #icon>
<Icon
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
class="size-4 flex-shrink-0"
/>
</template>
</Button>
<input
type="range"
min="0"
:max="duration || 0"
:value="currentTime"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
@input="seek"
/>
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
{{ displayedTime }}
</span>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
:label="playbackSpeedLabel"
class="!px-1 min-w-6 !text-n-slate-11"
@click="changePlaybackSpeed"
/>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-11"
@click="downloadRecording"
>
<template #icon>
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
</template>
</Button>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { CALL_KIND } from './constants';
const props = defineProps({
kind: {
type: String,
required: true,
},
});
const { t } = useI18n();
const KIND_CONFIG = {
[CALL_KIND.ONGOING]: {
icon: 'i-lucide-phone-call',
class: 'bg-n-teal-3 text-n-teal-11',
},
[CALL_KIND.INCOMING]: {
icon: 'i-lucide-phone-incoming',
class: 'bg-n-slate-3 text-n-slate-11',
},
[CALL_KIND.OUTGOING]: {
icon: 'i-lucide-phone-outgoing',
class: 'bg-n-slate-3 text-n-slate-11',
},
[CALL_KIND.MISSED]: {
icon: 'i-lucide-phone-missed',
class: 'bg-n-ruby-3 text-n-ruby-11',
},
[CALL_KIND.NO_REPLY]: {
icon: 'i-lucide-phone-outgoing',
class: 'bg-n-amber-3 text-n-amber-11',
},
[CALL_KIND.FAILED]: {
icon: 'i-lucide-phone-off',
class: 'bg-n-ruby-3 text-n-ruby-11',
},
};
const config = computed(() => KIND_CONFIG[props.kind]);
</script>
<template>
<span
class="inline-flex items-center justify-center w-20 gap-1.5 h-6 px-1 rounded-md text-label-small shrink-0"
:class="config.class"
>
<Icon :icon="config.icon" class="size-3 flex-shrink-0" />
<span class="truncate">{{
t(`CALLS_PAGE.STATUS.${kind.toUpperCase()}`)
}}</span>
</span>
</template>
@@ -0,0 +1,34 @@
<script setup>
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const setupVoiceChannel = () => {
router.push({
name: 'settings_inbox_new',
params: { accountId: route.params.accountId },
});
};
</script>
<template>
<EmptyStateLayout
:title="t('CALLS_PAGE.SETUP.TITLE')"
:subtitle="t('CALLS_PAGE.SETUP.SUBTITLE')"
:show-backdrop="false"
:action-perms="['administrator']"
>
<template #actions>
<Button
:label="t('CALLS_PAGE.SETUP.ACTION')"
icon="i-lucide-plus"
@click="setupVoiceChannel"
/>
</template>
</EmptyStateLayout>
</template>

Some files were not shown because too many files have changed in this diff Show More