Agents with limited custom roles were receiving notifications (creation,
assignment, mentions, new messages, SLA) for conversations they couldn't
actually open. For example, an agent whose custom role only grants
`conversation_unassigned_manage` was getting notified about
conversations assigned to other agents.
Notifications now go through the same `ConversationPolicy#show?` check
that gates the conversation view itself, so an agent only gets notified
for conversations they're permitted to see. Administrators and agents
without custom roles are unaffected.
---------
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Adds the server-side flow that turns Meta WhatsApp Cloud Calling
webhooks into Chatwoot Calls, conversations, voice_call message bubbles,
and ActionCable broadcasts. Stacked on top of #14312 (PR-2 — provider
methods); intentionally does not include the HTTP controller, routes, or
frontend (those land in PR-4 and PR-9).
## Closes
- Part of the WhatsApp Cloud Calling rollout. Linear: TBD
## What changed
**Webhook routing**
- `app/jobs/webhooks/whatsapp_events_job.rb` — append
`prepend_mod_with('Webhooks::WhatsappEventsJob')` so EE can extend it
without forking.
- `enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb` (new)
— overlay that prepends `handle_message_events` to intercept `field:
'calls'` payloads (route to `Whatsapp::IncomingCallService`) and
`interactive.call_permission_reply` messages (route to
`Whatsapp::CallPermissionReplyService`); falls through with `super` for
regular messages.
**Services**
- `enterprise/app/services/whatsapp/incoming_call_service.rb` (new) —
gated on `provider_config['calling_enabled']`; processes `connect`
(creates inbound call via `Voice::InboundCallBuilder` or transitions an
existing outbound call to `in_progress`) and `terminate` events; updates
conversation `additional_attributes` and broadcasts
`voice_call.incoming`/`voice_call.outbound_connected`/`voice_call.ended`.
- `enterprise/app/services/whatsapp/call_permission_reply_service.rb`
(new) — handles WhatsApp interactive `call_permission_reply` replies;
clears the conversation's `call_permission_requested_at` flag and
broadcasts `voice_call.permission_granted` so the agent UI can re-enable
the call button.
**Builder/model adjustments**
- `enterprise/app/services/voice/inbound_call_builder.rb` —
provider-agnostic; accepts `provider:` and `extra_meta:` kwargs, drops
`account:` (now derived from `inbox.account` to keep the param count
under rubocop's ceiling without disabling cops), uses digits-only
`source_id` for WhatsApp ContactInbox (validation requires
`^\d{1,15}\z`), skips Twilio-only `conference_sid` for non-Twilio
providers.
- `enterprise/app/services/voice/call_message_builder.rb` — adds
`create!`/`update_status!` API and `CALL_TO_VOICE_STATUS` map; uses
direct `Message.create!` (bypasses `Messages::MessageBuilder`'s
incoming-on-non-Api-inbox guard, which would otherwise reject the system
bubble); content is `'WhatsApp Call'` for WhatsApp and `'Voice Call'`
for Twilio. Backwards-compatible `perform!` retained for the existing
Twilio call sites.
- `enterprise/app/models/call.rb` — adds `default_ice_servers` (driven
by `VOICE_CALL_STUN_URLS` env), `direction_label` alias for the
`inbound`/`outbound` strings the FE expects, and
`ringing?`/`in_progress?`/`terminal?` predicates used throughout the
pipeline.
**Outgoing-channel guard**
- `app/services/base/send_on_channel_service.rb` — extends
`invalid_message?` to skip messages with `content_type == 'voice_call'`.
Without this, agent-initiated outbound calls (PR-4) would deliver
\"WhatsApp Call\" as a text message to the contact every time.
**Twilio call-site update**
- `enterprise/app/controllers/twilio/voice_controller.rb` — drops the
now-redundant `account: current_account` kwarg from the
`Voice::InboundCallBuilder.perform!` call.
**Tests**
- New: `spec/enterprise/services/whatsapp/incoming_call_service_spec.rb`
(5 examples — calling-disabled, inbound connect, outbound connect,
terminate completed, terminate no-answer, unknown event).
- New:
`spec/enterprise/services/whatsapp/call_permission_reply_service_spec.rb`
(3 examples — accept, reject, calling-disabled).
- Updated: `spec/enterprise/services/voice/inbound_call_builder_spec.rb`
and `spec/enterprise/controllers/twilio/voice_controller_spec.rb` to
drop the `account:` kwarg from call expectations.
## How to test
In `rails console` against an account with a WhatsApp inbox where
`provider_config['calling_enabled']` is true:
```ruby
inbox = Inbox.find(<id>)
params = { calls: [{ id: 'wacid_test', from: '15550001111', event: 'connect',
session: { sdp: 'v=0...', sdp_type: 'offer' } }] }
Whatsapp::IncomingCallService.new(inbox: inbox, params: params).perform
# => Conversation + Call (status: 'ringing', provider: 'whatsapp') + voice_call message bubble
# => ActionCable broadcasts `voice_call.incoming` to the assignee or account-wide
# Then terminate it:
Whatsapp::IncomingCallService.new(inbox: inbox,
params: { calls: [{ id: 'wacid_test', event: 'terminate', duration: 0, terminate_reason: 'no_answer' }] }
).perform
# => Call status flips to 'no_answer', message bubble updates, `voice_call.ended` broadcast fires
```
End-to-end browser flow (Meta → cable → UI) requires the controller from
PR-4 and the frontend from PR-9.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a new account finishes onboarding we want to land them on a
dashboard with a working web widget already configured, branded, named,
and assigned to them, instead of an empty inbox list. This PR adds the
services that produce that widget. **No user-visible change yet:** the
services are dormant until the trigger and background job are wired up
in the follow-up PR.
## Context
Milestone 1 added `Account::BrandingEnrichmentJob`, which calls
context.dev during signup and stores brand data on
`account.custom_attributes['brand_info']`, plus the new onboarding form
that captures `domain`, `name`, `industry`, etc. Milestone 2 starts
using that data, and the first thing we want is a web widget
materialized automatically. Splitting the service layer from the
orchestration plumbing (Redis key, `onboarding_step` extension,
controller wiring, ActionCable) keeps this diff focused and lets the
LLM/widget logic merge independently.
## How to test
Run against an existing account that already has `brand_info` populated.
```ruby
account = Account.find(<account_id>)
user = account.administrators.first
inbox = WidgetCreationService.new(account, user).perform
inbox.channel.widget_color # color from brand_info, or '#1f93ff'
inbox.channel.welcome_title # brand_info[:title], or account.name
inbox.channel.welcome_tagline # LLM tagline (Enterprise + system key set),
# else brand_info[:slogan]/[:description]/nil
inbox.inbox_members.pluck(:user_id)
```
Toggle `InstallationConfig['CAPTAIN_OPEN_AI_API_KEY']` to flip between
LLM and brand-text tagline paths. To verify failure isolation, raise
inside `Captain::Llm::WidgetTaglineService#perform` and confirm widget
creation still succeeds with the fallback tagline.
The SafeFetch spec suite was failing in CI with `NameError:
uninitialized constant SafeFetch::Fetcher` across every example that
exercised `SafeFetch.fetch`. From a product perspective, this made the
external-file fetch path look unreliable even though the failure
happened before any network validation, SSRF protection, content-type
checks, or tempfile handling could run.
The symptom pointed to a load-order issue rather than an actual fetch
behavior regression. `SafeFetch.fetch` referenced `Fetcher` from the
top-level module, but that nested class was not guaranteed to be loaded
in every test execution path before the method was invoked.
This change keeps the existing SafeFetch split between the public API
and the implementation classes, but makes the public entry point
responsible for loading the implementation it needs before use. That is
intentionally smaller than folding all of the fetcher logic into
`lib/safe_fetch.rb`; the separate files still keep the request option
parsing and streaming implementation readable, while the public API no
longer depends on Rails or the test runner having loaded nested
constants in a particular order.
The file also now uses a single `SafeFetch` module declaration. That
removes the awkward reopen pattern and makes the dependency boundary
easier to see: constants and errors are defined first, then the public
`fetch` method loads and delegates to the implementation classes.
# Pull Request Template
## Description
Bump RubyLLM version and update model registry
## Type of change
Version bump on package
## 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
# Pull Request Template
## Description
This PR adds IMAP authentication mechanism selection to Chatwoot's email
inbox configuration. Users can now choose between 'plain', 'login', and
'cram-md5' authentication methods when configuring IMAP settings,
providing flexibility for different email providers that require
specific authentication types.
https://github.com/chatwoot/chatwoot/issues/8867
The implementation includes:
- Frontend dropdown with numeric keys (1, 2, 3) matching SMTP auth style
- Backend API validation for allowed authentication mechanisms
- Consistent 'cram-md5' format throughout the codebase
- Updated IMAP service to handle different auth types properly
This feature maintains consistency with existing SMTP authentication
options and follows the established UI/UX patterns in the application.
## Type of change
Please delete options that are not relevant.
- [x] New feature (non-breaking change which adds functionality)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] 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?
### Manual Testing:
- Tested in Docker environment
- Verified IMAP auth dropdown appears in inbox settings
- Confirmed all three auth mechanisms (plain, login, cram-md5) can be
selected and saved
- Tested API validation by attempting to save invalid auth mechanisms
### Automated Testing:
- Updated existing IMAP service tests to use consistent lowercase values
- Updated API controller tests for authentication parameter handling
- All tests pass locally with the new changes
### Test Configuration:
- Tested with both new and existing inbox configurations
## 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
## Additional Notes
- This feature is backward compatible and doesn't break existing IMAP
configurations
- The 'cram-md5' format is used consistently throughout (UI, API,
storage, services)
- Net::IMAP compatibility is maintained by converting to 'CRAM-MD5'
internally
- Follows the same pattern established by SMTP authentication
configuration
---------
Co-authored-by: João Santos <joao.santos@madigital.eu>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
## Summary
- Remove label deletion dependency on association cleanup by deleting
immediately and enqueueing a background job.
- Add `Labels::RemoveAssociationsJob` to strip deleted label references
from tagged conversations and contacts.
- Keep this version simple by removing the label count/prompt
requirement requested.
## Implementation notes
- Enqueue job from `Api::V1::Accounts::LabelsController#destroy` with
label title + account id.
- Background work performed in `Labels::DestroyService`.
## References
- Linear issue:
https://linear.app/chatwoot/issue/CW-4765/cw-2857-enhancement-removing-labels-is-inconsistent
- GitHub issue: https://github.com/chatwoot/chatwoot/issues/1249
## Testing
- `bundle exec rspec
spec/controllers/api/v1/accounts/labels_controller_spec.rb
spec/services/labels/destroy_service_spec.rb
spec/jobs/labels/remove_associations_job_spec.rb
spec/services/labels/update_service_spec.rb`
- `bundle exec rubocop
app/controllers/api/v1/accounts/labels_controller.rb
app/jobs/labels/remove_associations_job.rb
spec/controllers/api/v1/accounts/labels_controller_spec.rb
spec/jobs/labels/remove_associations_job_spec.rb
spec/services/labels/destroy_service_spec.rb`
---------
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Custom attribute definitions can now only be created, edited, or deleted
by administrators, matching the existing settings UI restriction.
Previously, an agent could call the `custom_attribute_definitions` API
directly and modify account configuration that they couldn't reach
through the dashboard — a Broken Access Control vulnerability reported
externally.
Fixes
https://linear.app/chatwoot/issue/CW-7038/broken-access-control-on-custom-attribute-definitions-api
## How to test
1. Sign in as an agent.
2. Try to create a custom attribute by calling `POST
/api/v1/accounts/<id>/custom_attribute_definitions` directly (the
settings page is hidden for agents — use curl with the agent's
`api_access_token`).
3. Expect `401 Unauthorized` with body `{"error":"You are not authorized
to do this action"}`. Repeat for `PATCH` and `DELETE`.
4. Sign in as an administrator and confirm create/edit/delete still work
from Settings → Custom Attributes.
5. As either role, the listing endpoint (`GET
.../custom_attribute_definitions`) should still succeed — agents need
this to render attributes in conversation and contact panels.
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-6875/captain-credits-3-bugs-in-stripe-subscription-lifecycle-cancel-ratchet
## Description
Fixes Captain credit settlement on subscription cancellation. Previously
`limits['captain_responses']` and `captain_responses_usage` were left in
their pre-cancellation state, which caused incorrect credit totals when
a customer re-subscribed. Cancellation now settles the monthly allotment
(preserving any remaining topup) and resets the usage counter.
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
1. Set up an account subscribed to a paid plan (e.g. Startups) so
`limits['captain_responses']` reflects the plan allotment.
2. Fire `customer.subscription.deleted` for that account's Stripe
customer. Confirm the limits.
3. Fire `customer.subscription.updated` re-subscribing to the paid plan.
Confirm the limits.
4. Repeat cancel → re-subscribe several times;
## 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
`SendReplyJob` was caching reloadable service class objects in
`CHANNEL_SERVICES`. In test, a request spec can trigger Rails constant
reloading after `SendReplyJob` has already been loaded, leaving the job
with stale class objects while later specs stub the reloaded constants.
This resolves the channel service at perform time so the job follows the
current Rails constant table.
How to reproduce
Run the CircleCI shard that contains send_reply_job_spec, or the
minimized order-dependent reproduction:
```sh
bundle exec rspec --format progress spec/builders/v2/reports/label_summary_builder_spec.rb spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb spec/jobs/send_reply_job_spec.rb:32
```
What changed
- Store service class names in `SendReplyJob::CHANNEL_SERVICES` instead
of class objects.
- Resolve the service with constantize inside perform so reloads do not
leave stale cached classes.
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
# Pull Request Template
## Description
This PR fixes the non-functional resend confirmation feature on the V3
login page where clicking "Resend confirmation" did nothing. The issue
was caused by the V3 store not having the `resendConfirmation` action
that the login page was trying to dispatch.
**Key improvements:**
- Fixed V3 store integration by importing `resendConfirmation` directly
from auth API
- Added comprehensive UX improvements with loading states and 60-second
cooldown timer
- Implemented environment-aware debug logging for development
- Added proper error handling and user feedback
- Enhanced backend test coverage
**Context:** Users with unconfirmed accounts were unable to resend
confirmation emails from the login page, creating a poor user experience
and potential support burden.
Fixes#3157
## 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?
**Backend Testing:**
- All existing resend_confirmation tests passing (7/7)
- Added comprehensive new test suite in
`spec/requests/api/v1/resend_confirmation_spec.rb`
- API endpoint returns 200 OK responses in ~0.39 seconds
- Email delivery confirmed via SMTP with test user `info@airbonar.com`
**Frontend Testing:**
- All frontend tests passing
- ESLint compliant code with automatic corrections applied
- Manual testing of login page functionality:
- 60-second cooldown timer with countdown display
- Error handling with user-friendly messages
- Development logging works (console output in dev mode only)
**Test Configuration:**
- Ruby/Rails backend with RSpec test suite
- Vue.js frontend with Jest/testing-library
- Development environment with Gmail SMTP configured
- Test user: unconfirmed account `info@airbonar.com`
**Reproduction Steps:**
1. Navigate to login page with unconfirmed account
2. Click "Resend confirmation link"
3. Observe loading state, API call, and success feedback
4. Verify 60-second cooldown prevents spam
5. Check email delivery.
## Checklist:
- [ ] 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: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
## Description
This pull request addresses issue #11948, where inline images embedded
in emails (such as those sent from Outlook) are not rendered correctly
if the Content-Disposition header is missing.
The solution ensures that images referenced via cid: in the HTML body
are correctly identified and rewritten using url_for.
Fixes#11948
## Type of change
Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Added test: detects image inline attachment by cid reference when
Content-Disposition is missing
## 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
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Pranav <pranav@chatwoot.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>
Adds label support to contact import and export so teams can carry
approved contact labels through CSV workflows. Imports accept a `labels`
column with labels that already exist in the account; multiple labels
should be entered as a quoted comma-separated CSV value, for example
`"customer,vip"`.
Imports are additive: they add labels to contacts and do not remove
labels already on a contact. Removing a label from the CSV row or
leaving the `labels` cell blank will not clear existing contact labels.
To remove a label, edit the contact directly.
## Closes
- Closes#8535
## How to test
1. Create a few contact labels in the account, such as `customer`,
`vip`, and `lead`.
2. Go to Contacts -> Import contacts and download the sample CSV.
3. Import contacts with a `labels` column. Use a single label like
`lead`, or quote multiple labels like `"customer,vip"`.
4. Confirm imported contacts are created with the expected labels.
5. Re-import an existing contact with a new label and confirm the new
label is added without removing existing labels.
6. Try a row with an unknown label, such as `"vip,unknown_label"`, and
confirm only that row is rejected in the failed records CSV while the
other valid rows are imported.
7. Export contacts and confirm the CSV includes a `labels` column with
comma-separated approved labels.
## What changed
- Contact exports include approved `labels` in the default CSV columns.
This adds a new default export column for CSV consumers.
- Contact imports parse `labels` as comma-separated values inside the
CSV cell.
- Imported labels are validated against labels that already exist in the
account.
- Rows with unknown labels are rejected with an `Unknown labels: ...`
error; valid rows in the same import continue to process.
- Imported labels are additive and do not remove existing contact
labels.
- Label application during import does not dispatch an additional
per-contact update event.
- The sample CSV includes an import-safe `labels` column. The modal
keeps the existing generic CSV import copy.
---------
Co-authored-by: Sojan Jose <sojan@pepalo.com>
# Pull Request Template
## Description
skip documents that fail with ActiveRecord errors possibly due to
stale/corrupt data and not crash scheduler
How did we find out about this error?
before October 28th, 2025, we did not have url normalisation.
so we had document rows as:
id: 123 `https://example.com` status: `in_progress` --> likely stuck
crawl
id 234: `https://example.com/` status: `available`
When the schedule sync job ran, it ran an `document.update!(sync_status:
:syncing, last_sync_attempted_at: Time.current)` on the 234 one since it
was `available`
now `update!` runs `before_validation :normalize_external_link`
so `https://example.com/` became `https://example.com`
which invalidated:
`validates :external_link, uniqueness: { scope: :assistant_id }`
so the scheduler crashed.
This PR logs the skipped ones with their errors and continues to pick
other documents to scheduler doesn't crash
## 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.
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
Embed templates interpolate regex captures from user-authored article
URLs into HTML attribute values. CommonMark's angle-bracket link
destination syntax allows characters that the capture regexes don't
filter, so the unescaped substitution could produce malformed attribute
output. Escaping at substitution time keeps the render deterministic
regardless of the URL.
### How was this tested?
Added specs.
Fixes [CW-6934](https://linear.app/chatwoot/issue/CW-6934/)
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
The default `GITHUB_TOKEN` cannot read `security-advisories`; that endpoint requires the `repository_advisories` permission, which is not available to the GitHub Actions installation token.
Switched to a fine-grained PAT stored in `GHSA_READ_TOKEN`.
Tested locally: the same PAT returns the full triage list
Changes
----
- Switch to custom token
- Add a discord alert for new advisories
- Switch to python
## Description
* Added Meta webhook HMAC validation in meta_token_verify_concern.rb.
* Wired it into instagram_controller.rb and whatsapp_controller.rb.
* WhatsApp now verifies X-Hub-Signature-256 with WHATSAPP_APP_SECRET.
* Instagram now verifies with either FB_APP_SECRET or
INSTAGRAM_APP_SECRET.
* Updated request specs so missing/invalid signatures return 401 and
valid signatures still enqueue jobs.
Fixes # (issue):
[CW-6786](https://linear.app/chatwoot/issue/CW-6786/ghsa-7rw7-pc8v-mrr3-unauthenticated-message-injection-via-missing)
## Type of change
Please delete options that are not relevant.
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
* Updated the controller specs and ran them successfully.
* The original issue is no longer reproducible.
## 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: Muhsin Keloth <muhsinkeramam@gmail.com>
# Pull Request Template
## Description
Captain (v1) makes false promises by saying it will handoff but doesn't.
This happens due to an exact string match comparison and the prompt
gives the model a lot of responsibilities:
- identity
- what to respond
- obey custom instructions
- decide on tool calls
This PR decouples responsibility, the core prompt responds, and an
additional llm call evaluates if handoff was needed or not after that
message.
## 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
# Pull Request Template
## Description
Fixes
https://linear.app/chatwoot/issue/CW-6903/signature-delimiter-renders-as-h2-when-using-enter-line-before
**1**. Fixes an issue where the signature delimiter `--` gets parsed as
an H2 when using **Enter** (new paragraph) before or after it, causing
it to render as a bold `\` in the message bubble.
* Ensures `--` renders as plain text
* Aligns renderer with parser behavior (both disable `lheading`)
* Prevents stray `\` from appearing as heading text
**2**. Also fixes a related editor issue where toggling signature
**off** leaves behind a stray `\` or `-- \`.
* Strips blank paragraph markers (`\`) and dangling hard breaks
(`\<newline>`) from ProseMirror serializer
* Applied in both `appendSignature` and `removeSignature`
* Replaces `trimEnd()` with shared helpers (`trimTrailingBlanks` /
`stripTrailingBlankMarkers`)
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
#### Screenshots
**Before**
<img width="194" height="204" alt="image"
src="https://github.com/user-attachments/assets/b286ab50-7f89-4910-a552-1568902b93b3"
/>
**After**
<img width="194" height="220" alt="image"
src="https://github.com/user-attachments/assets/658cd543-bce2-46e2-a319-35e5374f1aef"
/>
**Editor**
https://linear.app/chatwoot/issue/CW-6903/signature-delimiter-renders-as-in-h2-when-using-enter-line#comment-5814b882
### Steps
#### Editor
1. Enable agent signature
2. Add and remove new lines around the signature using Enter/shift enter
3. Toggle signature off
4. Notice stray `\` or `-- \` remains
#### Bubble
1. Enable agent signature
2. Send a message using Enter between lines
3. Verify `--` renders correctly (no H2, no bold `\`)
## 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>
# Pull Request Template
## Description
This PR fixes multiple issues related to regex patterns and validation
for custom attributes.
1. Fixed regex patterns being double-escaped when saving from Add and
Edit flows
2. Fixed regex validation not being enforced in the widget pre-chat form
3. Minor UI improvements in the Add/Edit custom attribute dialog
Fixes
[CW-6625](https://linear.app/chatwoot/issue/CW-6625/bug-report-custom-attribute-regex-validation-not-working-in-ui),
https://github.com/chatwoot/chatwoot/issues/13771
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
**Loom video**
**Before**
https://www.loom.com/share/14f1983a8bc84f9fabc3663afd83cd50
**After**
https://www.loom.com/share/867c0484741140c1944fcbd43914c9c0
## 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
Two production-grade fixes to the existing audio transcription service.
**Independent of the WhatsApp Calling work** — these affect every audio
attachment that goes through Whisper (voice notes, call recordings,
voicemails, etc.).
## Closes
- [PLA-151 — PR-5: Recording Upload + Transcription
Pipeline](https://linear.app/chatwoot/issue/PLA-151/pr-5-recording-upload-transcription-pipeline)
## Why this is needed
### 1. Whisper rejects payloads larger than 25 MB
OpenAI's [Whisper
API](https://platform.openai.com/docs/guides/speech-to-text) hard-caps
file uploads at 25 MB. Long audio recordings — voice notes from chatty
contacts, ~70+ min Opus call recordings — currently hit OpenAI with the
full payload and 413 (\`Payload Too Large\`). The job retries via the
existing \`Faraday::BadRequestError\` discard path, but the agent still
sees a transcription failure for an attachment we knew was too big up
front.
This PR adds a pre-flight \`audio_too_large?\` check via the blob's
\`byte_size\` and returns a controlled error without hitting OpenAI. The
audio attachment is preserved (agents can still listen), only the
transcription is skipped.
### 2. Whisper hallucinates on silence at non-zero temperature
At \`temperature: 0.4\` (the previous value), Whisper produces
well-documented hallucinated repeats on silence and near-silent segments
— e.g. \`Oh, dear. Oh, dear. Oh, dear.\` filling the transcript. This
shows up in real recordings whenever there's a hold or quiet moment.
\`temperature: 0.0\` matches OpenAI's recommended default for
transcription and eliminates the spirals.
Reference:
[openai/whisper#928](https://github.com/openai/whisper/discussions/928),
[openai-python#1010](https://github.com/openai/openai-python/issues/1010).
## Are WhatsApp call recordings already handled?
Yes — by the existing pipeline, **before this PR**:
\`\`\`
Browser MediaRecorder → upload_recording (PR-4)
→ @call.message.attachments.create!(file_type: :audio, ...)
→ Enterprise::Concerns::Attachment#enqueue_audio_transcription
(after_create_commit hook)
→ Messages::AudioTranscriptionJob.perform_later(attachment.id)
→ Messages::AudioTranscriptionService → Whisper
\`\`\`
The \`after_create_commit\` hook already fires for every audio
attachment regardless of source. PR-4's \`upload_recording\` endpoint
creates the attachment; the existing job/service take it from there. No
new wiring needed.
This PR just makes the existing service more robust:
- Calls longer than ~70 min (Opus 48 kbps) no longer 413 against OpenAI
- Quiet recordings no longer produce hallucinated transcripts
## How to test
\`\`\`ruby
# In rails console with a real audio attachment:
service = Messages::AudioTranscriptionService.new(Attachment.audio.last)
# Normal-sized audio: unchanged behaviour
service.perform # => { success: true, transcriptions: ... }
# Large audio: new guard returns error instead of 413-ing OpenAI
allow(attachment.file.blob).to
receive(:byte_size).and_return(30.megabytes)
service.perform # => { error: 'Audio too large for Whisper' }
\`\`\`
Existing transcription specs cover the happy path; one new spec
exercises the byte-limit guard.
## Risk
Low. Both changes are pre-flight guards or parameter values — they
reduce the surface of OpenAI calls that can fail. Failure to transcribe
is already non-fatal (the audio attachment is preserved either way).
When an inbound voice call ends, the conversation bubble now (1) renders
an inline audio player as soon as Twilio finishes the recording and (2)
shows the call duration alongside "Call ended" so the agent gets the
at-a-glance summary without opening the recording.
Fixes
https://linear.app/chatwoot/issue/PLA-118/feat-recordings-on-calls-should-be-attached-on-the-conversation
and
https://linear.app/chatwoot/issue/PLA-119/duration-of-the-call-is-not-visible-on-the-chat-bubble
## How to test
1. Set up a Twilio voice inbox and trigger an inbound call.
2. Answer the call from an agent, talk for a few seconds, then hang up.
3. As soon as the call ends, the bubble should read **"Call ended —
0:NN"** (where NN is the call duration in seconds).
4. Wait a few seconds for Twilio to finish processing the recording
(usually <30s after hangup).
5. The same bubble should now show an inline audio player below the
duration. Press play; the recording should be audible.
6. Refresh the page — both the duration and the player should still be
there.
7. End a second call on the same conversation — its bubble should get
its own duration + player, independent of the first.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
- Fix CodeQL alerts by declaring read-only GITHUB_TOKEN scope at the
workflow level. The codespace image publish workflow additionally needs
packages: write to push to ghcr.io.
Agents can now click **Join call** directly on the incoming call bubble
in the conversation timeline. If they refresh the page or miss the
floating widget while a call is still ringing, the bubble becomes the
recovery affordance — one click joins the conference, no need to wait
for the next event.
The button only appears when the call is still ringing, no other agent
has claimed it, and the conversation is unassigned or assigned to the
current agent (mirroring the floating widget's eligibility rules). It
disappears as soon as anyone joins the call or it ends.
Fixes
https://linear.app/chatwoot/issue/PLA-117/ability-to-join-the-call-by-clicking-on-call-bubble-in-a-conversation
## How to test
1. Set up a Twilio voice inbox and trigger an inbound call to it.
2. As an agent who is eligible to answer (unassigned conversation, or
assigned to you), open the conversation **without answering from the
floating widget**. The bubble should show a teal **Join call** link
under "Not answered yet".
3. Refresh the page mid-ring — the link should still be there.
4. Click **Join call** — you should be connected to the conference, the
bubble should flip to "Call in progress / You answered", and the link
should disappear.
5. As a second agent who is **not** eligible (conversation assigned to
someone else), open the same conversation — the link should not appear.
6. Wait for the call to end — the bubble should show "Call ended" with
no Join link.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This PR limits IMAP email fetching to 500 messages per sync run to avoid
expensive/long-running mailbox scans. It also filters out
already-imported emails and Chatwoot-generated notification emails
during the header fetch phase, before fetching full email bodies,
reducing unnecessary IMAP work.
Fixes #CW-7001 (issue) :
https://linear.app/chatwoot/issue/CW-7001/emails-not-syncing
# Pull Request Template
## Description
This PR fixes an issue where agent variables like
`{{agent.name}}`,`{{agent.first_name}}`, `{{agent.last_name}}`, and
`{{agent.email}}` were not rendering in automation messages.
In automation, these either showed blank or returned `Liquid error:
internal`, while the same variables worked fine in macros.
**Cause**
Automation messages are created without a sender, so agent data was
missing during variable rendering. This also caused errors in name
handling, and `email` was not defined at all.
**Solution**
* Handle missing agent data safely to avoid errors
* Add support for `{{agent.email}}`
* Fallback to conversation assignee when sender is not present
Fixes
https://linear.app/chatwoot/issue/CW-6979/template-variables-not-working-in-automated-messages
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Screenshots
**Automation**
<img width="759" height="284" alt="image"
src="https://github.com/user-attachments/assets/61a877b7-4984-4a7f-bbef-b8c510dcbdfe"
/>
**Before**
<img width="404" height="105" alt="image"
src="https://github.com/user-attachments/assets/da665ce8-137d-4249-8ee5-a1acc11391db"
/>
**After**
<img width="564" height="132" alt="image"
src="https://github.com/user-attachments/assets/6a80d67c-49c8-4658-b782-ae4acbc77256"
/>
## 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
Adds the Meta WhatsApp Cloud API surface needed for browser-based
calling. This is the second slice of the WhatsApp calling feature,
sitting on top of `feat/voice-call-model-wiring` and consumed by later
PRs (incoming-webhook pipeline, call service, frontend).
This PR ships only the provider-level HTTP wrapper and one error class.
It is feature-flag-free and does not change any user-visible behaviour
on its own — without later PRs, no caller invokes these methods.
## Linear
-
https://linear.app/chatwoot/issue/PLA-148/pr-2-meta-cloud-api-provider-methods
## What changed
- Add `Whatsapp::Providers::WhatsappCloudCallMethods`
(`enterprise/app/services/whatsapp/providers/whatsapp_cloud_call_methods.rb`)
wrapping six Meta endpoints:
- `pre_accept_call`, `accept_call`, `reject_call`, `terminate_call` —
`POST /{phone_id}/calls` with the relevant action payload.
- `send_call_permission_request` — `POST /{phone_id}/messages`
interactive `call_permission_request`.
- `initiate_call` — `POST /{phone_id}/calls` with `audio`/`offer`
session.
- Prepend the module into `Whatsapp::Providers::WhatsappCloudService`
only if defined, so OSS continues to work without the enterprise
overlay.
- Add `Voice::CallErrors::NoCallPermission`
(`enterprise/lib/voice/call_errors.rb`) — raised when Meta returns error
code `138006` from `initiate_call`. The remaining call-service errors
(`NotRinging`, `AlreadyAccepted`, `CallFailed`) will land with PR-4.
## How to test
There is no UI in this PR. Smoke-test from a Rails console with a
WhatsApp inbox configured for calling:
```ruby
inbox = Inbox.find(<id>)
svc = inbox.channel.provider_service
svc.respond_to?(:initiate_call) # => true
svc.respond_to?(:send_call_permission_request) # => true
# Optional live calls (require a real phone + Meta call-permission opt-in):
svc.send_call_permission_request('15551234567')
svc.initiate_call('15551234567', '<sdp_offer>')
```
Failure path: `initiate_call` against a contact who has not granted call
permission should raise `Voice::CallErrors::NoCallPermission` with
Meta's user-facing message.
When an agent shares a conversation link copied from a custom view (e.g.
/custom_view/{id}/conversations/{id}), the link previously broke for
recipients who didn't have access to that custom view. The conversation
now loads regardless — if the custom view isn't available to the
recipient, they're redirected to the direct conversation URL.
### How to reproduce
1. As Agent A, open a conversation from inside a personal custom view
and copy the URL from the address bar.
2. Share the URL with Agent B who does not have access to that custom
view.
3. Before this fix, the link failed to load the conversation. After this
fix, Agent B lands on the conversation via the direct URL.
### What changed
- Added a beforeEnter guard on the conversations_through_folders route.
It checks the user's available conversation custom views (fetching them
on demand for deep links), and if the foldersId in the URL isn't among
them, redirects to the inbox_conversation route with the same
conversation_id.
---------
Co-authored-by: iamsivin <iamsivin@gmail.com>
### Description
Inbound voice calls now route ownership cleanly: the call widget is
hidden from agents who aren't the conversation assignee, the first agent
to pick up becomes the assignee, and any later join attempt by another
agent is rejected with a clear "<agent> is already handling the call."
alert.
Closes
https://linear.app/chatwoot/issue/PLA-98/inbound-voice-calls-assignment-aware-visibility-auto-assignment-on
### How to test
1. As Agent A and Agent B, open the dashboard for the same voice inbox
in two browsers.
2. Place an inbound call to the inbox with the conversation
**unassigned** — both agents should see the call widget.
3. Have Agent A click **Join**. Agent A's widget transitions to the
active call; Agent B's widget disappears (conversation is now assigned
to Agent A).
4. While the call is in progress, attempt to join from a third agent
(e.g., via the bubble in the conversation timeline) — the join is
rejected with the toast `Agent A is already handling the call.`
5. Resolve the conversation, then place a second call to a conversation
that is already manually assigned to Agent A — only Agent A sees the
widget; nobody else does.
6. Race test: trigger two near-simultaneous join attempts (two agents
click Join within a few hundred ms of each other) — exactly one wins;
the other gets the conflict alert.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Twilio voice now uses first-class `Call` records as the source of truth
for call state, instead of storing it on
`conversation.additional_attributes` and `conversation.identifier`. Each
call gets its own record, its own `voice_call` bubble matched by
`call_sid`, and its own conference name keyed off `Call.id`. Multiple
calls on the same conversation (for `lock_to_single_conversation`
inboxes) now work correctly, and the conversation card stays in sync
with the real latest message.
Fixes https://linear.app/chatwoot/issue/PLA-121/lock-to-single-thread
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
When an agent mentions themselves in a private note, they no longer
receive a redundant notification for their own mention.
Closes: #4096
# Pull Request Template
## Description
Agents who mention themselves in a private note no longer receive a
conversation_mention notification. Previously, the mention service would
generate a notification for every mentioned user without checking
whether the sender and the
mentioned user were the same person.
This PR fixes an issue where Slack emojis are rendered as text
shortcodes (e.g. 🚀) instead of the actual emoji characters in
Chatwoot messages.
It introduces a new EmojiFormatter class that uses the emoji-data
mapping to convert shortcodes to unicode characters.
---------
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
The `ChatwootApp.chatwoot_cloud?` gate on the platform banners route in
#13943 reads `InstallationConfig` from the database. Because `routes.rb`
is evaluated during `Rails.application.initialize!`, this ran before the
database existed on a fresh setup, breaking `bundle exec rake db:create`
in CI and first-time installs with `ActiveRecord::NoDatabaseError: We
could not find your database: chatwoot_test`.
The route is now always mounted, and the cloud check moved to where the
database is guaranteed to be available — the controller
(`before_action`) and the super admin sidebar partial.
Closes the CI failure introduced by #13943.
## How to test
1. Drop your local databases: `bundle exec rake db:drop`
2. Run `bundle exec rake db:create` — it should succeed (previously
failed with `NoDatabaseError`)
3. Bring the DB back: `bundle exec rake db:setup`
4. On a non-cloud install, visit `/super_admin/platform_banners` —
should 404, and the sidebar entry should be hidden
5. With `DEPLOYMENT_ENV=cloud` configured (cloud install), the page and
sidebar entry should work as before
## What changed
- `config/routes.rb` — always mount `resources :platform_banners` (no DB
call at boot)
- `app/controllers/super_admin/platform_banners_controller.rb` —
`before_action` raises `ActionController::RoutingError` (404) when not
on Chatwoot Cloud
- `app/views/super_admin/application/_navigation.html.erb` — hides the
sidebar entry on non-cloud installs
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Adds a platform-wide status banner system to notify all users about
external service outages. Super Admins can create, edit, and manage
banners via the Super Admin console. Banners support markdown for links
and are dismissible by users.
<img width="1099" height="236" alt="image"
src="https://github.com/user-attachments/assets/047a7994-d885-4a8a-b9c4-aeb32f15474a"
/>
## How to test
1. Set `ENABLE_PLATFORM_BANNERS=true` in your environment
2. Go to Super Admin → Platform Banners
3. Create a banner with a message like: `Elevated error rates from Meta
APIs. [Check status](https://metastatus.com)`
4. Select a banner type: `info` (blue), `warning` (amber), or `error`
(red)
5. Visit the dashboard — the banner should appear at the top
6. Click "Dismiss" — the banner hides and stays dismissed across page
reloads
7. Deactivate the banner in Super Admin — it disappears on next page
load
## What changed
- New `PlatformBanner` model with `banner_message`, `banner_type`
(info/warning/error), and `active` flag
- Super Admin CRUD via Administrate (controller, dashboard, routes,
sidebar icon)
- `DashboardController` serves active banners via `globalConfig`
- `StatusBanner.vue` component renders banners with markdown support and
per-banner localStorage dismiss
- Feature gated behind `ENABLE_PLATFORM_BANNERS` env var
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
# Pull Request Template
## Description
- Wires up Controllers to auto-sync job
- adds plan based sync schedule
- a scheduler that runs every hour to check syncable documents
- guards the whole feature behind feature flag by reclaiming
`twilio_content_templates`
- Adds a global and account level cap on how many documents to enqueue
to prevent sudden burst at first run
- some refactor to simplify code
- specs
Fixes # (issue)
## 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.
specs and 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
Voice calling is now a capability on the existing TwilioSms rather than
a separate Voice model. A single Twilio phone number handles both SMS
and voice calls through one inbox.
Fixes
https://linear.app/chatwoot/issue/CW-6683/add-voice-calling-as-a-capability-on-twilio-sms-channel
and https://linear.app/chatwoot/issue/PLA-120/add-the-support-for-sms
**What changed**
- Replaced Channel::Voice with voice_enabled flag on Channel::TwilioSms
- Added voice_enabled, twiml_app_sid, api_key_secret columns to
channel_twilio_sms table
- Dropped channel_voice table (no production data)
- All voice logic lives in Enterprise layer via
prepend_mod_with('Channel::TwilioSms')
- Added Voice settings tab on Twilio SMS inbox settings to
enable/disable voice
- Validates Twilio number voice capability before provisioning
- Teardown service cleans up TwiML app and credentials when voice is
disabled
- Frontend voice detection uses isVoiceCallEnabled() /
getVoiceCallProvider() helpers — extensible to future providers
- Gated by channel_voice feature flag
**How to test**
1. Enable feature flag:
Account.find(<id>).enable_features('channel_voice')
2. Create voice inbox: Inboxes → Voice tile → enter Twilio credentials →
verify incoming/outgoing calls and SMS work
3. Enable voice on existing SMS inbox: Inboxes → select Twilio SMS inbox
→ Voice tab → toggle on → provide API key credentials → verify calls
work
4. Disable voice: Voice tab → toggle off → verify TwiML app is deleted,
credentials cleared, SMS still works
5. Re-enable voice: Toggle on again → must provide api_key_secret again
→ new TwiML app provisioned
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
## Description
Adds Playwright E2E testing infrastructure with project configuration
and a login flow test. This is
Phase 1 of the Playwright E2E suite, kept minimal with only the core
setup and login component.
Ref: Discussion #13500, PR #13067
## Type of change
Please delete options that are not relevant.
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
- Verified all imports resolve correctly
(`login-flow-ui-validation.spec.ts` only imports `Login` from
`@components/ui`)
- Ran login flow test locally against a running Chatwoot instance
## 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] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
---------
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
## Description
Fix a Postgres planner trap on the "Pending Response: Longest first"
sort that causes the conversation list to hang on busy accounts.
The current `sort_on_waiting_since` generates query with `ORDER BY
waiting_since ASC NULLS LAST, created_at ASC`. That order-by is exactly
the shape of the single-column `index_conversations_on_waiting_since`
btree, so the planner picks a forward index walk thinking `LIMIT 25`
will stop early. In practice the per-account matches are spread along
the global waiting_since timeline, so the scan reads tens of millions of
rows from other accounts and discards them via the filter before
producing any results which in turn causes the requests to time out and
the conversation list spinner never resolves.
DESC direction and every other sort (`priority`, `created_at`,
`last_activity_at`) are unaffected. They fall through to
`conv_acid_inbid_stat_asgnid_idx` (account-scoped composite), which is
the right index for this access pattern.
This change leads the ORDER BY with the expression `(waiting_since IS
NULL)`, which no column-only btree can satisfy. The planner falls back
to the same account-scoped index used by every other sort, and sorts in
memory. Same logical NULLS LAST output for both directions; no behavior
change for users.
Fixes CW-6965
## 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?
- [x] Existing specs pass
- [x] Added new specs to cover NULL case
- [x] Verify results and order for old and new query in prod
- [x] Tested in prod since staging data was not sufficient
`EXPLAIN (ANALYZE, BUFFERS)` for the same query (status=0, ASC, LIMIT
25) on a representative production account, before vs after:
| Metric | Before | After |
| --- | --- | --- |
| Execution time | 34,679 ms | 0.71 ms |
| Rows discarded by filter | 36,906,962 | 0 |
| Shared buffer hits | 12,699,924 | 11 |
| Blocks read from disk | 851,226 | 112 |
| I/O read time | 17,785 ms | 0.3 ms |
| Pages dirtied / written | 98 / 31,043 | 0 / 0 |
Verified on two production accounts: identical row IDs in identical
order between the old and new ORDER BY for both ASC and DESC. A
NULL-bucket regression spec was added covering ASC/DESC tail ordering
when some conversations have a null `waiting_since`.
Roughly `49,000×` faster on this query (34,679 ms → 0.71 ms), and
trivially less I/O and buffer pressure on the cluster while it runs.
## 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: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
# Pull Request Template
## Description
This PR fixes an issue where outgoing Email messages (via API) do not
preserve single line breaks in rendered HTML.
#### Cause
Messages are stored with `\n`, but rendering differs:
* **Other channel** (`markdown-it`, `breaks: true`) → `\n` → `<br>`
* **Email** (CommonMark) without `HARDBREAKS` → `\n` collapsed into
spaces
Result: multi-line messages appear as a single paragraph in Email.
#### Solution
* Added `hardbreaks:` option to `render_message` (default: false)
* Enabled `hardbreaks: true` in `EmailHelper#render_email_html`
This ensures `\n` renders as `<br />` in Email, matching web widget
behavior.
Fixes
https://linear.app/chatwoot/issue/CW-6941/outgoing-email-messages-strip-single-newlines-from-plain-text-content
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
#### Screenshots
**Before**
<img width="604" height="104" alt="image"
src="https://github.com/user-attachments/assets/f9086ffb-a5c7-4688-99aa-97ea5edcccde"
/>
**After**
<img width="604" height="210" alt="image"
src="https://github.com/user-attachments/assets/a8f21c76-bcb8-4058-937a-dd185fb6745c"
/>
## 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>
## Context
Same root-cause as #7822 / #14078, but in a second file that PR #14078
doesn't touch:
\`app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue\`.
\`countries.json\` entries only have \`id\` (e.g. \`"AF"\`) — there is
no \`code\` field. So:
\`\`\`js
const countriesMap = countries.reduce((acc, country) => {
acc[country.code] = country; // country.code is undefined →
acc[undefined] = country
acc[country.id] = country;
return acc;
}, {});
\`\`\`
…overwrites \`acc[undefined]\` on every iteration, leaving whichever
country comes last in the list (Zimbabwe, \`id: "ZW"\`). Later, the
\`countryDetails\` lookup falls back to that value whenever the
contact's \`country\` / \`countryCode\` is missing or unknown, and
Zimbabwe is displayed incorrectly.
## Fix
One-line delete — drop the dead \`acc[country.code] = country\` write.
Lookups by ISO code continue to work via \`country.id\`.
## Scope
Only the search-results card. The Contacts card is already being fixed
in #14078 with the same one-line delete. It's worth patching this
surface too so the same symptom doesn't reappear when the same contact
is accessed via \`Ctrl+K\` search.
## Test plan
- [ ] Reproduce: search for a contact with \`country: null\` /
\`countryCode: null\` — before this patch the flag renders as Zimbabwe;
after, it renders as no country (current expected fallback).
- [ ] Search for a contact with a valid ISO \`countryCode\` (e.g.
\`"IN"\`) — country still resolves correctly.
- [ ] Contacts list page (\`ContactsCard.vue\`, fixed in #14078) — no
regression.
## Follow-up (not in this PR)
Both components keep rebuilding the same \`countriesMap\` per mount. A
small \`shared/constants/countries.js\` export (\`export const byId =
countries.reduce(...)\`, computed once at module load) would save the
per-mount cost and centralise the shape so this bug can't return.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
When a WhatsApp contact starts a new conversation by sending multiple
images at once (an album), each image arrives as a separate webhook.
Because no conversation exists yet, the concurrent workers each pass the
"does a conversation exist?" check and each create their own
conversation — producing one conversation per image instead of one
grouped conversation.
This fix serializes webhook processing per `(inbox, contact)` using a
Redis lock at the job level, so only one webhook at a time can create
the initial conversation for a given contact. Concurrent workers retry
with backoff and append to the same conversation once the lock is
released.
## Closes
- Closes#13261
## How to test
1. On a WhatsApp inbox, ensure there is no active (open) conversation
with a specific test contact — resolve or delete any existing one.
2. From a phone, select 6+ images in the WhatsApp gallery and send them
as a single album to the Chatwoot-connected number.
3. Open the Chatwoot dashboard and confirm exactly **one** new
conversation is created, with all images grouped under it.
4. Repeat the test with a mix of attachment types (XMLs, PDFs, images)
sent in rapid succession — still one conversation.
## What changed
- New Redis key `WHATSAPP_MESSAGE_CREATE_LOCK::<inbox_id>::<sender_id>`
in `lib/redis/redis_keys.rb`.
- `Webhooks::WhatsappEventsJob` now inherits from `MutexApplicationJob`
and wraps event processing in `with_lock(key)`, matching the pattern
already used by `FacebookEventsJob`, `InstagramEventsJob`, and
`TiktokEventsJob`.
- Uses `retry_on LockAcquisitionError, wait: 1.second, attempts: 8` so
concurrent webhooks retry until the lock is free instead of poll-waiting
inside the service.
- Sender ID is derived from the webhook payload (contact's `from`, or
`to` for SMB echo events); status-only webhooks bypass the lock.
- Issue 1 from the report (same `source_id` redelivery) was already
handled previously by `Whatsapp::MessageDedupLock` (atomic `SET NX EX`);
no changes needed there.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a customer responds to a bot's interactive prompt (input_select,
input_csat, form, input_email) from the widget, the response shows up in
the Chatwoot agent UI but is not reflected in the linked Slack channel —
Slack only ever shows the original question. This happens because the
widget submits the answer as an UPDATE to the original message (writing
`content_attributes.submitted_values` or `submitted_email`), but the
Slack hook only listened to `message.created`, so updates were ignored.
Closes https://linear.app/chatwoot/issue/PLA-147
### Preview
<img width="1290" height="1106" alt="CleanShot 2026-04-21 at 13 19
19@2x"
src="https://github.com/user-attachments/assets/cd2a9d3f-89d3-4e81-9230-5b078e1b7b44"
/>
### How to test
1. Connect a web widget inbox to a Slack channel.
2. Trigger each bot message type (input_select, form, input_csat,
input_email) in a conversation.
3. Submit responses from the widget.
4. Verify each response now appears in the Slack thread, appended to the
original bot question.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
## Description
This PR resolves an issue that was causing Zimbabwe country being
selected in the Contacts list.
The root cause is objects in `countries.json` file are missing the
`code` property which was causing the countries map to always have the
last country in the list also map to an `undefined` key. In turn, this
was causing an error when contact's `country` was undefined.
Additional thing to keep in mind: I am not sure if there is a case when
contact's `country` property is used or if it can be removed as well,
but in my Chatwoot installation contacts only have a `countryCode`
property, and there is no way to set `country` from the widget sdk.
Fixes#7822
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Simply running the vue application locally targeting my live chatwoot
API.
## 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-04-28 11:36:40 +05:30
481 changed files with 59575 additions and 6168 deletions
body:"Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
types:{
'twilio/text':{
body:"Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
},
},
status:'approved',
category:'utility',
language:'en',
variables:{
1:'John',
3:'12345',
},
content_sid:'HX88291ef8d30d7dcd436cbb9b21c236f4',
friendly_name:'order_status',
template_type:'text',
},
{
body:'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
types:{
'twilio/text':{
body:'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
},
},
status:'approved',
category:'utility',
language:'en',
variables:{},
content_sid:'HXdc6da32d489ee80f67c07d5bb0e7e390',
friendly_name:'hello_world',
template_type:'text',
},
{
body:'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
types:{
'twilio/quick-reply':{
body:'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
actions:[
{
id:'Sales_payload',
title:'Sales',
},
{
id:'Support_payload',
title:'Support',
},
],
},
},
status:'approved',
category:'utility',
language:'en_US',
variables:{},
content_sid:'HX778677f5867f96175ab4b7efb9a5bee6',
friendly_name:'welcome_message_new',
template_type:'quick_reply',
},
{
body:'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
types:{
'twilio/quick-reply':{
body:'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
actions:[
{
id:'Chatwoot Cloud_payload',
title:'Chatwoot Cloud',
},
{
id:'Self-hosted Chatwoot_payload',
title:'Self-hosted Chatwoot',
},
],
},
},
status:'approved',
category:'utility',
language:'en_US',
variables:{},
content_sid:'HX3a35e5cd76529fd91d19341deb4ef685',
friendly_name:'saas_whatsapp_question',
template_type:'quick_reply',
},
{
body:'Thanks for reaching out to us. Happy to help. What are you looking for?',
types:{
'twilio/quick-reply':{
body:'Thanks for reaching out to us. Happy to help. What are you looking for?',
actions:[
{
id:'Support_payload',
title:'Support',
},
{
id:'Sales_payload',
title:'Sales',
},
{
id:'Demo_payload',
title:'Demo',
},
],
},
},
status:'approved',
category:'utility',
language:'en_US',
variables:{},
content_sid:'HX5d8e09f96cee2f7fb7bab223c03cb0a1',
friendly_name:'welcome_message',
template_type:'quick_reply',
},
{
types:{
'twilio/call-to-action':{
actions:[
{
id:null,
title:'Pay now',
type:'URL',
url:'https://payments.example.com/pay',
},
],
body:'Hello, this is a gentle reminder regarding your course fee.\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
},
},
status:'approved',
category:'utility',
language:'en',
variables:{
date:'01-Jan-2026',
},
content_sid:'HX63e56fc142aad670f320bf400d5bfeb7',
friendly_name:'course_fee_reminder',
template_type:'call_to_action',
},
];
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.