For a reported 6-second call the agent OGG was 11.82s and the combined
OGG was 11.77s. Root cause: the browser's mic starts sending RTP to the
media server as soon as Peer B completes WebRTC — ~5s before the
contact actually picks up on an outbound call. Those pre-answer seconds
were captured into agent.ogg, and ffmpeg's longest-duration mix
inherited the inflated length.
Recorder now drops RTP on each side until the other side has produced
at least one packet. Writes start only when both tracks are live, which
aligns the recording with the real conversation window and matches
Meta's authoritative duration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inbound calls started failing DTLS again with state=failed ~2.5s after
ICE connected. Keeping DTLSRoleServer (so Meta is the DTLS client and
only starts ClientHello after it has our fingerprint) but adding
SetDTLSInsecureSkipHelloVerify(true) — Meta's WhatsApp calling stack
loses cookies between retransmits on occasion, and skipping HelloVerify
lets pion accept the first ClientHello in one round-trip rather than
rejecting the retry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When Meta's terminate webhook ended a call the recording never reached
Rails, so neither the audio nor the transcript appeared in the UI. Two
linked bugs:
1. IncomingCallService#handle_call_terminate enqueued
CallRecordingFetchJob without first telling the media server to
terminate the session. combined.ogg is produced by ffmpeg inside
Recorder.Finalize, which only runs on session termination. The fetch
therefore targeted a session whose combined file did not exist yet
→ 404 → no attachment → no transcript. CallService#terminate (agent
hang-up path) worked because it did call terminate_session first.
2. Once the Rails side did call terminate_session, Manager evicted the
session from memory immediately. Any subsequent per-side downloads
(the diarization path that fetches customer.ogg + agent.ogg) then
404'd even though the files were still on disk.
Fixes:
- CallRecordingFetchJob now calls terminate_session before downloading.
It's idempotent on the server; returns only after Finalize has written
combined.ogg.
- GetRecording in the media server falls back to looking up files on
disk by session id when the session is no longer in the manager, so
per-side downloads succeed after termination.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs tracked to the same root cause: the media-server's recorder was
writing both the customer and agent RTP streams into a single oggwriter.
The two streams have unrelated SSRCs, sequence numbers, and timestamp
bases, so the resulting OGG carried non-monotonic granule positions.
Browsers compute duration from granule positions → the audio element
reported inflated times. Whisper parsed the broken container and
returned garbage, which showed up as nonsense transcripts.
media-server changes
- Recorder no longer writes to a combined oggwriter during the call.
Each direction is captured into its own clean OGG (customer-only and
agent-only).
- At Finalize, the two files are mixed with ffmpeg into a single
combined.ogg that has coherent OGG pages and correct duration. If
ffmpeg isn't on PATH we fall back to the customer-side file alone so
the pipeline doesn't break on bare hosts.
- GET /sessions/:id/recording now accepts ?side=customer|agent so Rails
can fetch the per-direction files for diarization.
Rails changes
- MediaServerClient#download_recording takes a `side:` keyword.
- CallTranscriptionService prefers per-side transcription when the
session has a media_session_id: downloads both files, runs Whisper
with response_format=verbose_json for segment timestamps, interleaves
by timestamp and labels lines Customer: / Agent:. Falls back to the
combined recording if per-side downloads fail.
- Lowered Whisper temperature from 0.4 to 0.2 for more deterministic
transcripts.
Setup: ffmpeg is now required for correct combined playback and
transcription. Install on macOS with `brew install ffmpeg`; on Linux
install the distro's ffmpeg package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inbound calls were reaching the media server, DTLS was completing after
the DTLSRoleServer fix, and yet no media flowed — Meta still timed out
at 20s. Tracing the session 182412 showed why: the browser uploaded a
local .webm recording at t+22s. That only happens on the legacy
browser-direct-to-Meta path, which means the browser negotiated WebRTC
straight with Meta instead of waiting for the media server's agent
offer.
Root cause: GET /whatsapp_calls/:id returns Meta's sdp_offer whenever
the call is ringing, regardless of media-server mode. The FE calls
that endpoint from acceptWhatsappCallById when the call isn't in the
incoming-calls store yet (page reload, bubble click). It then sees a
truthy sdpOffer, isServerRelayCall() returns false, and the browser
dials Meta itself. Meta's DTLS was with *our* media server, so the
browser's peer connection never got media and Meta saw silence.
Fix:
- show omits sdp_offer / ice_servers when media_server_enabled and
returns media_server_enabled as an explicit flag so the FE picks the
right path.
- broadcast_incoming_call carries the same flag through ActionCable.
- FE isServerRelayCall prefers the explicit flag; only falls back to
sdpOffer presence when the flag isn't provided.
- acceptWhatsappCallById and the ActionCable handler propagate the
flag into the call object.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With the new DTLS transport logging I could see the real failure mode
for inbound calls:
meta peer: DTLS state changed state=connecting
meta peer: DTLS state changed state=FAILED (~2.4s later)
Not a timeout — a fatal DTLS alert. Meta was rejecting our ClientHello
because it hadn't yet seen our SDP answer (the answer flows to Meta via
Rails' pre_accept_call / accept_call *after* create_session returns, so
for ~1 second Meta has no fingerprint to verify us against). Pion
treats the alert as fatal and closes the PC, which drops every later
audio packet we tried to forward to Meta → Meta times out at 20s with
error 138021.
Flip the roles with SetAnsweringDTLSRole(DTLSRoleServer). Meta now
answers its own offer's actpass as the DTLS client, and Meta only
starts ClientHello after it has our answer (so our fingerprint is
already known when we receive it). We've been idle until then and have
everything we need to complete the handshake in one round trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inbound calls consistently died at ~2s with the Meta PC going straight
from connecting → closed (never reaching connected). Root cause: pion
starts the DTLS handshake the moment we SetLocalDescription during
create_session, but Rails only delivers our SDP answer to Meta via
pre_accept_call / accept_call afterwards. During the ~1s gap Meta has
no fingerprint for us and drops our ClientHello. pion's default DTLS
retransmission window lapsed before Meta was ready to respond.
- Bump the DTLS connect context to a 30s timeout so retries keep going
until Meta has our credentials.
- Shorten the retransmission interval to 200ms so a retry lands quickly
once Meta is listening.
- Log DTLS transport state and the stack trace for explicit pc.Close()
so the next regression is easy to diagnose.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ICE state alone doesn't cover DTLS handshake failures; overall
PeerConnectionState does. Adding the log gives us a signal for why
inbound Meta-side connections transition to closed shortly after ICE
connects — useful for diagnosing the inbound-audio path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rails' /agent_answer forwards only the SDP answer to the media server; it
doesn't persist or echo the peer_id returned from /agent-offer. The
AgentAnswer handler required peer_id and 400'd on every Rails call, so
Peer B never completed its handshake. With no agent remote track, the
bridge delivered nothing to Meta, and Meta tore the call down after ~20s
with error 138021 ("WhatsApp client terminated the call due to not
receiving any media").
Add Session.SoleAgentPeerID and have the handler use it when peer_id is
absent. Single-agent sessions now complete negotiation without extra
Rails plumbing. Multi-agent sessions still require an explicit peer_id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The agent peer built its offer via AddTrack, which pion materializes as a
sendonly transceiver. The browser could therefore receive customer audio
but had no m-line to send its microphone — one-shot answer cannot add a
new m-line. Result: audio one-way at best, and in practice neither
direction worked because the bridge never saw an agent remote track.
Switch to AddTransceiverFromTrack with direction sendrecv, matching the
Meta-peer fix. The browser can now respond sendrecv and the bridge's
agent-to-Meta forwarding actually has a source.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The offer the media-server sent to Meta had two m=audio sections — one
sendonly (from AddTrack creating an implicit transceiver) plus one
recvonly (from an explicit AddTransceiverFromKind for outgoing calls).
Meta rejected every outbound call with error 100 \"Invalid parameter\"
at the terminate webhook, so the contact's phone never rang.
Bind the local audio track to a single sendrecv transceiver via
AddTransceiverFromTrack and drop the redundant recvonly transceiver.
The offer is now a single m=audio sendrecv line with BUNDLE 0, which is
what Meta expects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CreateSessionRequest rejected every Rails call with HTTP 400 — the Go
struct expects ice_servers[].urls as []string and account_id as string,
but Rails was sending urls as a single string and account_id as an
integer.
- MediaServerClient#create_session now wraps scalar `urls` in an array
and coerces account_id to a string before posting. Defensive
normalization lives in the client so existing Call rows (whose meta
was persisted in the old shape) keep working without a backfill.
- Default ICE server literals in IncomingCallService#default_ice_servers
and WhatsappCallsController#create_outbound_call_via_media_server
updated to the correct `urls: [..]` shape going forward.
Verified end-to-end via rails runner against a locally running
media-server: create_session → generate_agent_offer → terminate all
return 2xx for both incoming (with Meta SDP offer) and outgoing
(server generates the offer for Meta) directions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the four blockers identified in the review so the WhatsApp call
feature actually flows through the Go media-server sidecar:
1. MediaServerClient#create_session now sends `direction` — Go rejected
every session create before this (400). Threaded through to both
CallService#accept_via_media_server (incoming) and
WhatsappCallsController#create_outbound_call_via_media_server (outgoing).
2. Outbound SDP response key aligned — Rails now reads `meta_sdp_offer`
(the media-server's offer for Meta), not the old `meta_sdp_answer`
which never existed in the Go response.
3. Media-server callbacks moved to top-level /callbacks/media_server/<event>
with a new controller that skips Devise account scoping and is guarded
solely by MEDIA_SERVER_AUTH_TOKEN. Matches the Go client's existing
request path, so agent_disconnected / recording_ready / session_terminated
actually reach Rails instead of 404'ing on the account-auth gate.
4. Inbox serializer exposes `media_server_enabled` so the dashboard picks
the server-relay code path when the feature is enabled.
Also lands the outbound server-relay connect flow that was missing:
Go gains POST /sessions/{id}/meta-answer so Rails can feed Meta's SDP
answer back into Peer A when the contact picks up; the webhook handler
then asks the server for a Peer B offer and broadcasts it to the agent
browser instead of Meta's raw answer.
Procfile.dev runs the Go sidecar alongside Rails/sidekiq/vite in dev;
.env gets the three MEDIA_SERVER_* variables.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard sends the conversation's display_id (per-account sequential
number shown in URLs) as conversation_id. The controller was calling
`conversations.find()` which resolves by primary key, so every initiate
request 404'd. Align with the rest of the codebase by using
`find_by!(display_id: ...)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Local dev DB had unrelated migrations applied (account conversation
counters, agent capacity defaults, IMAP retry fields). Revert schema.rb
to only include changes from this PR's calls migrations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the in-repo planning/spec docs that were accumulated while iterating on
the feature. They duplicate one another and belong in an internal wiki, not
shipped with the PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements a Pion Go B2BUA media server sidecar that sits between Meta's
WhatsApp Cloud API and the agent's browser, enabling call persistence
across page reloads, server-side recording, multi-participant support,
audio injection, and AI integration readiness.
Go Media Server (enterprise/media-server/):
- Pion WebRTC v4 B2BUA with Peer A (Meta) and Peer B (Agent) connections
- Real-time OGG/Opus recording with crash recovery
- Audio bridge with multi-peer fan-out and AudioConsumer plugin interface
- Audio injection from OGG files with loop support for hold music
- Session manager with graceful shutdown and orphaned recording recovery
- HTTP API with Bearer token auth, health checks, and metrics
Rails Integration:
- Whatsapp::MediaServerClient HTTP client for Go sidecar communication
- Dual-mode CallService: legacy browser-direct and server-relay paths
- Media server callback controller for agent disconnect/recording/terminate
- CallRecordingFetchJob: downloads OGG from Go server to ActiveStorage/S3
- CallCleanupJob: sweeps stale ringing and in-progress calls
- New endpoints: active, agent_answer, reconnect, join, play_audio
- DB migration: media_session_id column with indexes
Frontend:
- Dual-mode composable auto-detecting legacy vs server-relay
- handleAgentOffer() for receiving SDP from media server
- useCallReconnection composable for page reload recovery
- Removed terminateCallOnUnload in server-relay mode
- Simplified outbound call flow in ConversationHeader
- New ActionCable event: whatsapp_call.agent_offer
Documentation:
- Server-side WebRTC architecture spec (1505 lines)
- Implementation plan with 119 trackable checklist items
- Feature spec, PR breakdown, and relay architecture docs
# Pull Request Template
## Description
This PR includes,
On Windows, pressing **Ctrl+Enter** in the reply editor was inserting an
unintended line break before sending. This led to two issues:
* **Unexpected blank lines**
After adding a line break with Shift+Enter and removing it with
Backspace, the editor looked correct. However, sending with Ctrl+Enter
reintroduced a hidden break, resulting in an extra blank line in the
final message.
* **Selected text being replaced**
When text was selected and Ctrl+Enter was pressed, the selection was
replaced with a line break instead of being sent.
Fixes
https://linear.app/chatwoot/issue/CW-6840/newline-bug-in-the-editor
### **Cause**
Two keyboard handlers responded to **Ctrl+Enter** on Windows:
* ProseMirror (`Mod-Enter`) inserted a hard break
* ReplyBox (`$mod+Enter`) triggered send
The existing guard only checked `metaKey` (Cmd), so it never worked on
Windows. As a result, a line break was inserted just before sending.
### **Solution**
Make the modifier check platform-aware so the editor correctly
intercepts the send shortcut:
* Added `detectOS`, `isMac`, and `OS` constants
* Introduced `hasPressedMod` (uses `metaKey` on macOS, `ctrlKey`
elsewhere)
This ensures Ctrl+Enter sends the message without modifying content,
while keeping existing behavior unchanged.
**NB:** macOS behavior with Cmd+Enter remains unchanged
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
**Case 1: line break**
1. Type `hello`
2. Press Shift+Enter, then Backspace
3. Press Ctrl+Enter
→ Message contains an unexpected blank new line
**Case 2: Selection replaced**
1. Type two lines using Shift+Enter
2. Select text on the second line
3. Press Ctrl+Enter
→ Selected text is replaced and not sent
### Screencast
**Before**
https://github.com/user-attachments/assets/d6d285a9-260b-4711-8bbd-d0c8519e8d20
**After**
https://github.com/user-attachments/assets/c0ace1f7-5d22-44a2-8e08-22190ee21e61
## 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
The "conversation continuity via email" toggle was visible to all
accounts regardless of whether they had `inbound_emails` enabled.
Without inbound email infrastructure, replies to those follow-up emails
land in the agent's personal inbox instead of routing back into
Chatwoot. The feature appears to work but silently breaks the reply
path.
The toggle is now gated on the `inbound_emails` feature flag. On
self-hosted without the feature, the toggle is hidden entirely. On
cloud, it remains visible but disabled with upgrade messaging.
On the backend, `inbound_emails` is added to the manually managed
features list in `InternalAttributesService` so that Stripe webhook plan
syncs don't override it when support enables it for an account.
---------
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
## PR2: Report builder refactor — DataSource abstraction
The existing report builders (timeseries + summary) had their SQL
queries inlined — each builder constructed its own scopes, groupings,
and aggregations directly. This made it hard to swap the underlying data
source without duplicating builder logic.
This PR extracts all raw-event querying into a `Reports::RawDataSource`
behind a `Reports::DataSource` factory. Builders now call
`data_source.timeseries`, `.aggregate`, or `.summary` instead of
constructing queries themselves. Behavior is identical —
`DataSource.for(...)` returns `RawDataSource` in all cases today.
The timeseries path had two separate builders (`CountReportBuilder`,
`AverageReportBuilder`) that were selected via a metric-name case
statement in `Conversations::BaseReportBuilder`. These are replaced by a
single `ReportBuilder` that delegates to the data source. The metric
type (count vs average) is now decided inside the data source, not the
builder.
Summary builders similarly moved their inline SQL into
`RawDataSource#summary`, which returns a unified hash keyed by dimension
ID.
the rollup read path.
## Flow
### Before
```
ReportsController ──▶ case metric ──▶ AverageReportBuilder ──▶ inline SQL ──▶ DB
└──▶ CountReportBuilder ──▶ inline SQL ──▶ DB
SummaryController ──▶ AgentSummaryBuilder ──▶ inline SQL ──▶ DB
└──▶ InboxSummaryBuilder ──▶ inline SQL ──▶ DB
└──▶ TeamSummaryBuilder ──▶ inline SQL ──▶ DB
```
### After
```
ReportsController ──▶ ReportBuilder ──┐
├──▶ DataSource.for ──▶ RawDataSource ──▶ DB
SummaryController ──▶ SummaryBuilder ──┘
```
### Expected (after rollup read path)
```
ReportsController ──▶ ReportBuilder ──┐
├──▶ DataSource.for ──▶ RawDataSource ──▶ reporting_events
SummaryController ──▶ SummaryBuilder ──┘ └──▶ RollupDataSource ──▶ reporting_events_rollups
```
### What changed
- `Reports::DataSource` factory + `Reports::RawDataSource`
- `TimezoneHelper#timezone_name_from_params` — prefers IANA name, falls
back to offset
- Unified `Timeseries::ReportBuilder` replaces `CountReportBuilder` +
`AverageReportBuilder`
- Summary builders delegate to `DataSource` instead of querying directly
### How to test
This is a pure refactor — all existing report pages (Overview, Agent,
Inbox, Label, Team) should produce identical numbers. No feature flag or
new config needed.
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Introduce a `Last Responding Agent` options to assign_agents action in
automations to cover the following use cases.
- Assign conversations to first responding agent : ( automation message
created at , if assignee is nil, assign last responding agent )
- Ensure conversations are not resolved with out an assignee : (
automation conversation resolved at : if assignee is nil, assign last
responding agent )
and potential other cases.
fixes: #1592
## Summary
The `assignee_name` and `user_name` variables are swapped in the Chinese
(zh/zh_CN) locale files for conversation assignment activity messages,
causing the rendered text to show the wrong person as the assignee.
### Before (incorrect)
| Template | English (correct) | Chinese (incorrect) |
|---|---|---|
| `assignee.assigned` | Assigned to **AgentA** by **Admin** | 由
**AgentA** 分配给 **Admin** |
| `team.assigned` | Assigned to **TeamX** by **Admin** | 由 **TeamX** 分配给
**Admin** |
| `team.assigned_with_assignee` | Assigned to **AgentA** via **TeamX**
by **Admin** | 由 **AgentA** 分配给 **TeamX** 团队的 **Admin** |
The Chinese text reads as if the conversation was assigned **to Admin**
(the API caller), when it was actually assigned **to AgentA**.
### After (correct)
| Template | Chinese (fixed) |
|---|---|
| `assignee.assigned` | 由 **Admin** 分配给 **AgentA** |
| `team.assigned` | 由 **Admin** 分配给 **TeamX** |
| `team.assigned_with_assignee` | 由 **Admin** 通过 **TeamX** 团队分配给
**AgentA** |
Now correctly matches the English template semantics.
## Files Changed
- `config/locales/zh_CN.yml` — 3 lines
- `config/locales/zh.yml` — 3 lines
## How to Verify
1. Set locale to `zh_CN`
2. Have an admin assign a conversation to an agent
3. Check the activity message in the conversation — the assignee name
should appear after "分配给", not before it
Co-authored-by: Sojan Jose <sojan@pepalo.com>
This updates macros and automations so agents can explicitly remove
assigned agents or teams, while keeping the existing `Assign -> None`
flow working for backward compatibility.
Fixes: #7551Closes: #7551
## Why
The original macro change exposed unassignment only through `Assign ->
None`, which made macros behave differently from automations and left
the explicit remove actions inconsistent across the product. This keeps
the lower-risk compatibility path and adds the explicit remove actions
requested in review.
## What this change does
- Adds `Remove Assigned Agent` and `Remove Assigned Team` as explicit
actions in macros.
- Adds the same explicit remove actions in automations.
- Keeps `Assign Agent -> None` and `Assign Team -> None` working for
existing behavior and stored payloads.
- Preserves backward compatibility for existing macro and automation
execution payloads.
- Downmerges the latest `develop` and resolves the conflicts while
keeping both the new remove actions and current `develop` behavior.
## Validation
- Verified both remove actions are available and selectable in the macro
editor.
- Verified both remove actions are available and selectable in the
automation builder.
- Applied a disposable macro with `Remove Assigned Agent` and `Remove
Assigned Team` on a real conversation and confirmed both fields were
cleared.
- Applied a disposable macro with `Assign Agent -> None` and `Assign
Team -> None` on a real conversation and confirmed both fields were
still cleared.
# Pull Request Template
## Description
This PR adds support for resizing the reply editor up to nearly half the
screen height. It also deprecates the old modal-based pop-out reply box,
clicking the same button now expands the editor inline. Users can adjust
the height using the slider or the expand button.
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
### Loom video
https://www.loom.com/share/be27e1c06d19475ab404289710b3b0da
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Pranav <pranav@chatwoot.com>
Update BulkSelectBar to compute selection state (indeterminate/all) from
visible item IDs and only toggle selection for visible items. Preserve
existing selection for off-screen items when toggling, and guard against
empty visibility. Add detection/rendering for an optional
secondary-actions slot and adjust layout/divider. Also fix
ContactsBulkActionBar selection logic to determine "all selected" by
verifying every visible ID is in the selection. These changes ensure
correct select-all behavior with filtered/visible lists and support
additional UI actions.
https://github.com/user-attachments/assets/d06b78d1-a64a-4c0c-a82a-f870140236c7
# 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 # (issue)
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
## Checklist:
- [ ] 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: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
RubyLLM bundles a static models.json that doesn't know about models
released after the gem was published. Self-hosted users configuring
newer models hit ModelNotFoundError.
Added a rake task that refreshes the registry from models.dev and saves
to disk. ~~Called during Docker image build so every deploy gets fresh
model data. Falls back silently to the bundled registry if models.dev is
unreachable.~~
Commit the models.json file to code so it is available across
deployments.
---------
Co-authored-by: Sojan Jose <sojan@pepalo.com>
# Pull Request Template
## Description
Add migrations for document auto-sync
Fixes # (issue)
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
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
## Summary
This PR enables the **Participating** conversation view in the main
sidebar and keeps the behavior aligned with existing conversation views.
## What changed
- Added **Participating** under Conversations in the new sidebar.
- Added a guard in conversation realtime `addConversation` flow so
generic `conversation.created` events are not injected while the user is
on Participating view.
- Added participating route mapping in conversation-list redirect helper
so list redirects resolve correctly to `/participating/conversations`.
## Scope notes
- Kept changes minimal and consistent with current `develop` behavior.
- No additional update-event filtering was added beyond what existing
views already do.
---------
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
## Linear ticket
https://linear.app/chatwoot/issue/CW-6839/blocked-contact-can-still-send-messages-to-whatsapp-inbox
## Description
Drop WhatsApp incoming messages from blocked contacts
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Incoming messages for blocked contacts
## 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>
# Pull Request Template
## Description
This PR adds inline editing support for contact name, phone number,
email, and company fields in the conversation contact sidebar
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
**Screencast**
https://github.com/user-attachments/assets/e9f8e37d-145b-4736-b27a-eb9ea66847bd
## 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: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
# Pull Request Template
## Description
### Description
This PR fixes an issue where the editor would reset content and move the
cursor while typing. The issue was caused by a dual debounce setup
(400ms + 2500ms) that saved content and then overwrote local state with
stale API responses while the user was still typing.
### What changed
* Editor now uses local state (`localTitle`, `localContent`) as the
source of truth while editing
* Vuex store is only used on initial load or navigation
* Replaced dual debounce with a single 500ms debounce (fewer API calls)
* `UPDATE_ARTICLE` now merges updates instead of replacing the article
* Prevents status changes from wiping unsaved content
* Removed `updateAsync` for a simpler update flow
### How it works
User types
→ local ref updates immediately (editor reads from this)
→ 500ms debounce triggers
→ dispatches `articles/update`
→ API persists the change
→ on success: store merges the response (used by other components)
→ editor remains unaffected (continues using local state)
Fixes
https://linear.app/chatwoot/issue/CW-6727/better-syncing-of-content-the-editor-randomly-updates-the-content
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
1. Open any Help Center article for editing
2. Type continuously for a few seconds — content should not reset or
jump
3. Change article status (publish/archive/draft) while editing — content
should remain intact
4. Test on a slow network (use DevTools throttling) — typing should
remain smooth
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
AI-generated summaries now respect the account's language setting.
Previously, summaries were always returned in English regardless of the
user's configured language, making section headings like "Customer
Intent" and "Action Items" appear in English even for non-English
accounts.
Previous behavior:
<img width="1336" height="790" alt="image"
src="https://github.com/user-attachments/assets/5df8b78b-1218-438d-9578-a806b5cb94ac"
/>
Current Behavior:
<img width="1253" height="372" alt="image"
src="https://github.com/user-attachments/assets/ae932c97-06da-4baf-9f77-9719bc9162e8"
/>
## What changed
- Added explicit account locale to the AI system prompt in
`Captain::SummaryService`
- Updated the summary prompt template to instruct the model to translate
section headings
## How to test
1. Configure an account with a non-English language (e.g., Portuguese)
2. Open a conversation with messages
3. Use the Copilot "Summarize" feature
4. Verify that section headings ("Customer Intent", "Conversation
Summary", etc.) appear in the account's language
---------
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Adds a Call model to track voice call state across providers (Twilio,
WhatsApp). This replaces storing call data in
conversation.additional_attributes and provides a foundation for call
analytics multi-call-per-conversation support, and future voice
providers.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
## Description
ConversationReplyMailer#parse_email calls
Mail::Address.new(email_string).address without error handling. When an
account's support_email contains a non-email string (e.g., "Smith
Smith"), the mail gem raises Mail::Field::IncompleteParseError, crashing
conversation transcript emails.
This has caused 1,056 errors on Sentry (EXTERNAL-CHATINC-JX) since Feb
25, all from a single account that has a name stored in the
support_email field instead of a valid email address.
Closes
https://linear.app/chatwoot/issue/CW-6687/mailfieldincompleteparseerror-mailaddresslist-can-not-parse-orsmith
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
## Linear ticket
https://linear.app/chatwoot/issue/CW-6834/billing-upgrade-didnt-work
## Description
A `customer.subscription.updated` Stripe webhook for account 76162
returned 200 OK but did not persist the new `subscribed_quantity`. Root
cause: a race condition between the webhook handler and
`increment_response_usage` (Captain usage counter), both doing
read-modify-write on the `custom_attributes` JSONB column. The webhook
wrote `quantity: 6`, then a concurrent `save` from
`increment_response_usage` overwrote the entire hash with stale data —
restoring `quantity: 5`.
Fix: use atomic `jsonb_set` so usage counter updates only touch the
single key they care about, instead of rewriting the whole
`custom_attributes` hash. `increment_custom_attribute` also performs the
increment in SQL, making concurrent increments correct as well.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- New regression spec in `handle_stripe_event_service_spec.rb` that
simulates concurrent webhook + `increment_response_usage` and asserts
both `subscribed_quantity` and `captain_responses_usage` survive
- Existing account, billing, captain, and topup specs all pass locally
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
Replace the WhatsappCall model with the unified Call model introduced
in feat/voice-call-model (PR #14026), enabling multi-provider support
(Twilio + WhatsApp) under a single data model.
Key changes:
- Delete WhatsappCall model and its 3 migrations (whatsapp_calls table)
- Enhance Call model with WhatsApp-specific helpers (sdp_offer,
ice_servers, recording_url, direction_label, ringing?, in_progress?)
- Update all backend services, controller, and jobs to use Call model
- Map statuses: accepted→in_progress, rejected→failed, missed→no_answer,
ended→completed
- Map directions: inbound→incoming, outbound→outgoing (enums)
- Rename call_id column to provider_call_id, always set provider: :whatsapp
- Add authorization check on initiate action
- Add race condition guard in outbound call_connect webhook handler
- Consolidate direction_label helper into Call model (DRY)
- Update frontend data key from waCallId to callId in VoiceCall bubble
- Remove whatsapp_calls table from schema.rb, fix missing started_at
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Label attach/detach against a shared label no longer deadlocks under
parallel load. During high-concurrency label writes (for example, a
broadcast script attaching a campaign label to many conversations at
once), Chatwoot previously hit periodic `ActiveRecord::Deadlocked`
errors and tail-latency spikes on the tags table. This PR removes the
contention by disabling the `acts-as-taggable-on` counter cache, which
Chatwoot never reads.
## Closes
Fixes [INF-68](https://linear.app/chatwoot/issue/INF-68) (event 2)
## How to reproduce
1. Seed an account with ~20 conversations and 5 labels.
2. Spawn 20 parallel threads, each calling
`conversation.update!(label_list: shared_labels.shuffle)` against
different conversations.
3. Observe `ActiveRecord::Deadlocked` exceptions and p99 label-write
latency well above 1s.
With the counter cache disabled, the deadlock cycle cannot form.
## How this was tested
- Ran a 20-thread synthetic load test locally, each thread attaching 5
shared labels (shuffled per request) to different conversations. With
the counter cache enabled: 8 deadlocks across 300 attempts, p99 ~2.2s.
With the counter cache disabled: zero deadlocks, p99 ~306ms (roughly 85%
tail-latency reduction). The `UPDATE tags SET taggings_count = ...`
statement disappears from the SQL log entirely.
- Verified at boot via `rails runner` that
`ActsAsTaggableOn::Tagging.reflect_on_association(:tag).options[:counter_cache]`
returns `false` after the initializer runs. The gem wires `belongs_to
:tag, counter_cache: ActsAsTaggableOn.tags_counter` at class-load time,
so the initializer must sit ahead of the `Tagging` autoload path; this
confirms it does.
## Description
The IMAP email fetch job (Inboxes::FetchImapEmailsJob) crashes with an
unhandled IOError: closed stream when the mail server's SSL socket is
closed mid-write during Net::IMAP#fetch. This error was being reported
to Sentry because the rescue clause only caught EOFError, not its parent
class IOError.
Fixes
[CW-6689](https://linear.app/chatwoot/issue/CW-6689/ioerror-closed-stream-ioerror)
Widened the rescue in fetch_imap_emails_job.rb from EOFError to IOError.
In Ruby's exception hierarchy, EOFError is a subclass of IOError:
```
StandardError
└── IOError
└── EOFError
```
The Sentry stacktrace shows a plain IOError: closed stream raised from
OpenSSL::Buffering#do_write → Net::IMAP#put_string → Net::IMAP#fetch.
Since this is an IOError (not EOFError), it bypassed the existing rescue
and fell through to the StandardError catch-all, which reported it to
Sentry as an unhandled exception.
Rescuing IOError now catches both:
IOError: closed stream — the reported crash (parent class)
EOFError — the previously handled case (still caught as a subclass)
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Routing errors (404s) are expected in production and don't represent
actionable issues. Reporting them to New Relic creates noise and makes
it harder to spot real errors. Adds ActionController::RoutingError to
the New Relic error_collector.ignore_errors list so these are no longer
tracked as exceptions.
Removes sentry flooding of unnecessary rubyllm logs of wrong API key.
Logs only system api key error since it would be P0.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## Description
Enable assignment v2 by default for new accounts
## 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
HandoffTool changes conversation status but only posts a private note.
ResponseBuilderJob now detects the tool flag and creates the public
handoff message that was previously only shown in V1.
# Pull Request Template
## Description
Captain V2 was silently forwarding conversations to humans without
showing a handoff message to the customer. The conversation appeared to
just stop
responding.
Root cause: In V2, HandoffTool calls bot_handoff! during agent
execution, which changes conversation status from pending to open. By
the time control returns
to ResponseBuilderJob#process_response, the conversation_pending? guard
returns early - skipping create_handoff_message entirely. The V1 flow
didn't have this
problem because AssistantChatService just returns a string token
(conversation_handoff) and lets ResponseBuilderJob handle everything.
What changed:
1. AgentRunnerService now surfaces the handoff_tool_called flag (already
tracked internally for usage metadata) in its response hash.
2. ResponseBuilderJob#handoff_requested? detects handoffs from both V1
(response token) and V2 (tool flag).
3. ResponseBuilderJob#process_response checks handoff_requested? before
the conversation_pending? guard, so V2 handoffs are processed even when
the status has
already changed.
4. ResponseBuilderJob#process_action('handoff') captures
conversation_pending? before calling bot_handoff! and uses that snapshot
to guard both bot_handoff!
and the OOO message - preventing double-execution when V2's HandoffTool
already ran them.
New V2 handoff flow:
AgentRunnerService
→ agent calls HandoffTool (creates private note, calls bot_handoff!)
→ returns response with handoff_tool_called: true
ResponseBuilderJob#process_response
→ handoff_requested? detects the flag
→ process_action('handoff')
→ create_handoff_message (public message for customer)
→ bot_handoff! skipped (conversation_pending? is false)
→ OOO skipped (conversation_pending? is false)
Fixes#13881
## 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?
- Update existing response_builder_job_spec.rb covering the V2 handoff
path, V2 normal response path, and V1 regression
- Updated existing agent_runner_service_spec.rb expectations for the new
handoff_tool_called key and added a context for when the flag is true
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Comprehensive update to Traditional Chinese (Taiwan) translations. As a
native zh-TW speaker and active user based in Taiwan, I found the
existing translations were quite incomplete (~54% overall) with many
strings still in English. Some existing translations also used
Simplified Chinese terms or unnatural phrasing.
I chose to submit this as a direct PR rather than going through Crowdin
because working through all the files at once is much faster and lets me
ensure consistent terminology across the entire locale.
Closes#14003
## What changed
**Backend (`config/locales/zh_TW.yml`)**
- Translated all ~259 previously untranslated strings (was ~19%
complete, now 100%)
- Covers: error messages, notifications, activity logs, integration
descriptions, Captain AI, public portal, reports
**Frontend (42 JSON files under `dashboard/i18n/locale/zh_TW/`)**
- Translated ~2,627 previously untranslated strings (was ~50% complete,
now ~100%)
- Most impacted files: `inboxMgmt.json`, `integrations.json`,
`settings.json`, `conversation.json`, `contact.json`, `report.json`
**Quality fixes across all files**
- Replaced Simplified Chinese terms mixed into zh-TW: 账→帳, 获→取得, 模板→範本,
收件箱→收件匣, 重置→重設, 自定義→自訂
- Standardized terminology for consistency: 客服人員 (agent), 延後 (snooze),
稽核 (audit), 巨集 (macro)
- Fixed incorrect translations (e.g., audit log table headers were
swapped, availability label was wrong)
## How to test
1. Set account/user language to 中文(台灣)
2. Navigate through the dashboard — settings, inbox management,
integrations, reports, conversations
3. Verify strings display in natural Traditional Chinese with no
remaining English gaps
4. Check that all placeholders (names, counts, dates) render correctly
# Pull Request Template
## Description
This PR includes, block inline images in message signatures and prevent
auto signature insertion when editor is disabled.
- Strip inline base64 images from signature on save and show warning
message
- Add `INLINE_IMAGE_WARNING` translation key for signature inline image
removal notification
- Add disabled check to `addSignature()` to prevent signature insertion
when editor is disabled
- Add `isEditorDisabled` checks to signature toggle logic in
`toggleSignatureForDraft()`, `replaceText()`, and `clearMessage()`
- Remove unused `replaceText` from the codebase, which belongs to old
`textarea` editor
Fixes
https://linear.app/chatwoot/issue/CW-6588/the-browser-hangs-when-the-message-signature-contains-inline-image
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Loom video
https://www.loom.com/share/fb556b46a12a4308a737eed732d5ed73
## 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>
## Account branding enrichment during signup
This PR does the following
### Replace Firecrawl with Context.dev
Switches the enterprise brand lookup from Firecrawl to Context.dev for
better data quality, built-in caching, and automatic filtering of
free/disposable email providers. The service interface changes from URL
to email input to match Context.dev's email endpoint. OSS still falls
back to basic HTML scraping with a normalized output shape across both
paths.
The enterprise path intentionally does not fall back to HTML scraping on
failure — speed matters more than completeness. We want the user on the
editable onboarding form fast, and a slow fallback scrape is worse than
letting them fill it in.
Requires `CONTEXT_DEV_API_KEY` in Super Admin → App Config. Without it,
falls back to OSS HTML scraping.
### Add job to enrich account details
After account creation, `Account::BrandingEnrichmentJob` looks up the
signup email and pre-fills the account name, colors, logos, social
links, and industry into `custom_attributes['brand_info']`.
The job signals completion via a short-lived Redis key (30s TTL) + an
ActionCable broadcast (`account.enrichment_completed`). The Redis key
lets the frontend distinguish "still running" from "finished with no
results."
Previously, signing up gave immediate access to the app. Now,
unconfirmed users are redirected to a verification page where they can
resend the confirmation email.
- After signup, the user is routed to `/auth/verify-email` instead of
the dashboard
- After login, unconfirmed users are redirected to the verification page
- The dashboard route guard catches unconfirmed users and redirects them
- `active_for_authentication?` is removed from the sessions controller
so unconfirmed users can authenticate — the frontend gates access
instead
- If the user visits the verification page after already confirming,
they're automatically redirected to the dashboard
- No session is issued until the user is verified
<details><summary>Demo</summary>
<p>
#### Fresh Signup
https://github.com/user-attachments/assets/abb735e5-7c8e-44a2-801c-96d9e4823e51
#### Google Fresh Signup
https://github.com/user-attachments/assets/ab9e389a-a604-4a9d-b492-219e6d94ee3f
#### Create new account from Dashboard
https://github.com/user-attachments/assets/c456690d-1946-4e0b-834b-ad8efcea8369
</p>
</details>
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
# Pull Request Template
## Description
Custom tools is now discoverable on all plans
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Before:
<img width="390" height="446" alt="CleanShot 2026-04-02 at 13 40 11@2x"
src="https://github.com/user-attachments/assets/0a751954-f3ad-47d6-85b8-1e2f1476a646"
/>
After:
<img width="392" height="522" alt="CleanShot 2026-04-02 at 13 40 47@2x"
src="https://github.com/user-attachments/assets/62a252f6-2551-47a9-b50c-be949f08c456"
/>
<img width="1826" height="638" alt="CleanShot 2026-04-02 at 13 37 39@2x"
src="https://github.com/user-attachments/assets/77dc2a75-3d76-44cf-8579-8d3457879bd0"
/>
## 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: Shivam Mishra <scm.mymail@gmail.com>
Fixes failing `agent_bot_listener_spec.rb` tests for
`conversation_status_changed` events. After #13892 added webhook
signing, `process_webhook_bot_event` passes `:agent_bot_webhook` and
`secret:`/`delivery_id:` kwargs to
`AgentBots::WebhookJob.perform_later`, but two spec expectations were
not updated to match the new call signature.
## What changed
- Updated `perform_later` expectations in `conversation_status_changed`
specs to include the `:agent_bot_webhook` type and `secret` keyword
arguments.
🤖 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.6 (1M context) <noreply@anthropic.com>
Agent bots assigned to a conversation were not receiving
`conversation_status_changed` webhook events. This meant bots could not
react to status transitions like moving a conversation to **pending**.
The deprecated `conversation_opened` and `conversation_resolved` events
were still being delivered, but the newer unified
`conversation_status_changed` event was silently dropped because
`AgentBotListener` had no handler for it.
## What changed
- Added `conversation_status_changed` handler to `AgentBotListener`,
matching the pattern already used by `WebhookListener`. The payload
includes `changed_attributes` so bots know which status transition
occurred.
## How to test
1. Configure an agent bot with an `outgoing_url` (e.g. a webhook.site
endpoint).
2. Assign the bot to an inbox or conversation.
3. Change a conversation's status to **pending** (or any other status).
4. Verify the bot receives a `conversation_status_changed` event with
the correct `changed_attributes`.
🤖 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.6 (1M context) <noreply@anthropic.com>
Account webhooks sign outgoing payloads with HMAC-SHA256, but agent bot
and API inbox webhooks were delivered unsigned. This PR adds the same
signing to both.
Each model gets a dedicated `secret` column rather than reusing the
agent bot's `access_token` (for API auth back into Chatwoot) or the API
inbox's `hmac_token` (for inbound contact identity verification). These
serve different trust boundaries and shouldn't be coupled — rotating a
signing secret shouldn't invalidate API access or contact verification.
The existing `Webhooks::Trigger` already signs when a secret is present,
so the backend change is just passing `secret:` through to the jobs.
Shared token logic is extracted into a `WebhookSecretable` concern
included by `Webhook`, `AgentBot`, and `Channel::Api`. The frontend
reuses the existing `AccessToken` component for secret display. Secrets
are admin-only and excluded from enterprise audit logs.
### How to test
Point an agent bot or API inbox webhook URL at a request inspector. Send
a message and verify `X-Chatwoot-Signature` and `X-Chatwoot-Timestamp`
headers are present. Reset the secret from settings and confirm
subsequent deliveries use the new value.
---------
Co-authored-by: Sojan Jose <sojan@pepalo.com>
The `conversation_updated` webhook sent to AgentBots did not include
`changed_attributes`, making it impossible for bots to distinguish
between different types of conversation updates (e.g. bot assignment vs
label change vs status change).
This aligns the AgentBot webhook payload with the existing
`WebhookListener` behavior, which already includes `changed_attributes`.
## How to reproduce
1. Assign an AgentBot to a conversation
2. Then update the conversation (e.g. add a label)
3. **Before fix:** Both events arrive with identical payload structure —
bot cannot tell them apart
4. **After fix:** Each event includes `changed_attributes` showing
exactly what changed
## What changed
- **`AgentBotListener#conversation_updated`**: Added
`changed_attributes` to the webhook payload using
`extract_changed_attributes` (same pattern as `WebhookListener`)
## How to test
1. Assign an AgentBot to a conversation via API
2. Check the webhook payload — should include:
```json
"changed_attributes": [
{ "assignee_agent_bot_id": { "previous_value": null, "current_value": 7
} }
]
```
3. Update the conversation (e.g. add a label)
4. Check the webhook payload — `changed_attributes` should reflect the
label change, not bot assignment
🤖 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.6 (1M context) <noreply@anthropic.com>
## Description
Two improvements to Agent Capacity Policy:
**1. Support exclusion via zero conversation limit**
Allow `conversation_limit` to be `0` on inbox capacity limits. Agents
with a zero limit are excluded from auto-assignment for that inbox while
remaining members for manual assignment.
**2. Fix exclusion rules duration input**
- Default changed from `10` to `null` so time-based exclusion isn't
applied unless explicitly set.
- Minimum lowered from 10 to 1 minute.
- `DurationInput` updated to handle `null` values correctly.
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
- Added model and capacity service specs for zero-limit exclusion
behavior.
- Tested manually via 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>
## Summary
When a Super Admin creates a new account via the Administrate dashboard,
the `manually_managed_features` field (a virtual attribute stored in
`internal_attributes` JSON) is passed to `Account.new(...)`, raising
`ActiveModel::UnknownAttributeError`. The existing `update` action
already strips this param — this fix adds the same handling to `create`.
Closes -> https://linear.app/chatwoot/issue/INF-66
Related Sentry ->
https://chatwoot-p3.sentry.io/issues/7168237533/?project=6382945&referrer=Linear
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How to reproduce
1. Log in as Super Admin
2. Navigate to Accounts → New
3. Fill in the form (with or without manually managed features selected)
4. Submit → `ActiveModel::UnknownAttributeError: unknown attribute
'manually_managed_features' for Account`
## What changed
- Added a `create` override in
`Enterprise::SuperAdmin::AccountsController` that strips
`manually_managed_features` from params before calling `super`, then
persists them via `InternalAttributesService` after the account is
saved.
Previously, all incoming messages from Facebook channel with
instagram_id had their attachment data_url and thumb_url overridden with
external_url. This caused issues for non-Instagram conversations
originating from Facebook Message where the file URL should be used
instead.
Narrows the override to only apply when the conversation type is
instagram_direct_message, which is the only case where Instagram's CDN
URLs need to be used directly.
Fixes
https://linear.app/chatwoot/issue/CW-6722/videos-are-missing-in-facebook-conversation
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When an AgentBot is assigned to a conversation after the first message
has already been received, the bot does not respond because it never
receives any event. The `message_created` event fires before the bot is
assigned, and the bot has no way to know it was assigned.
Chatwoot already dispatches a `CONVERSATION_UPDATED` event when
`assignee_agent_bot_id` changes, but `AgentBotListener` wasn't listening
for it. This fix adds a `conversation_updated` handler so the bot
receives a webhook with the conversation context when assigned.
## How to reproduce
1. Customer sends a message → conversation created, `message_created`
fires
2. System processes the message (adds labels, custom attributes)
3. System assigns an AgentBot to the conversation via API
4. **Before fix:** Bot receives no event and never responds
5. **After fix:** Bot receives `conversation_updated` event with
conversation payload
## What changed
- **`AgentBotListener`**: Added `conversation_updated` handler that
sends the conversation webhook payload to the assigned bot when the
conversation is updated
## How to test
1. Create an AgentBot with an `outgoing_url` pointing to a webhook
inspector (e.g. webhook.site)
2. Send a message to create a conversation
3. Assign the AgentBot to the conversation via API:
```
POST /api/v1/accounts/{id}/conversations/{id}/assignments
{ "assignee_id": <bot_id>, "assignee_type": "AgentBot" }
```
4. Verify the bot receives a `conversation_updated` event at its webhook
URL
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
### Description
When integrating the web widget via the JS SDK, customers call
setConversationCustomAttributes and setLabel on chatwoot:ready — before
any conversation exists. These API calls silently fail because the
backend endpoints require an existing conversation. When the visitor
sends their first message, the conversation is created without those
attributes/labels, so the message_created webhook payload is missing the
expected metadata.
This change queues SDK-set conversation custom attributes and labels in
the widget store when no conversation exists yet, and includes them in
the API request when the first message (or attachment) creates the
conversation. The backend now permits and applies these params during
conversation creation — before the message is saved and webhooks fire.
### How to test
1. Configure a web widget without a pre-chat form.
2. Open the widget on a test page and run the following in the browser
console after chatwoot:ready:
`window.$chatwoot.setConversationCustomAttributes({ plan: 'enterprise'
});`
`window.$chatwoot.setLabel('vip');` // must be a label that exists in
the account
3. Send the first message from the widget.
4. Verify in the Chatwoot dashboard that the conversation has plan:
enterprise in custom attributes and the vip label applied.
5. Set up a webhook subscriber for `message_created` confirm the first
payload includes the conversation metadata.
6. Verify that calling `setConversationCustomAttributes` / `setLabel` on
an existing conversation still works as before (direct API path, no
regression).
7. Verify the pre-chat form flow still works as expected.
Attachment webhook event payloads (`message_created`) were missing the
file extension and content type. The `extension` column existed but was
never populated, and `content_type` was not included in the payload at
all.
## What changed
- Added `before_save :set_extension` callback to extract file extension
from the filename when saving an attachment.
- Added `content_type` (from ActiveStorage) to the `file_metadata` used
in `push_event_data`.
### Before
```json
{
"extension": null,
"data_url": "...",
"file_size": 11960
}
```
### After
```json
{
"extension": "pdf",
"content_type": "application/pdf",
"data_url": "...",
"file_size": 11960
}
```
## How to reproduce
1. Send a message with a file attachment (e.g., PDF) via any channel
2. Inspect the `message_created` webhook payload
3. Observe `extension` is `null` and `content_type` is missing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Pull Request Template
## Description
Adds custom tool support to v1
## 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.
<img width="1816" height="958" alt="CleanShot 2026-03-24 at 11 37 33@2x"
src="https://github.com/user-attachments/assets/2777a953-8b65-4a2d-88ec-39f395b3fb47"
/>
<img width="378" height="488" alt="CleanShot 2026-03-24 at 11 38 18@2x"
src="https://github.com/user-attachments/assets/f6973c99-efd0-40e4-90fe-4472a2f63cea"
/>
<img width="1884" height="1452" alt="CleanShot 2026-03-24 at 11 38
32@2x"
src="https://github.com/user-attachments/assets/9fba4fc4-0c33-46da-888a-52ec6bad6130"
/>
## Checklist:
- [x] 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: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
When a user signs up with an email they don't own and sets a password,
that password remains valid even after the real owner later signs in via
OAuth. This means the original registrant — who never proved ownership
of the email — retains working credentials on the account. This change
closes that gap by rotating the password to a random value whenever an
unconfirmed user completes an OAuth sign-in.
The check (`oauth_user_needs_password_reset?`) is evaluated before
`skip_confirmation!` runs, since confirmation would flip `confirmed_at`
and mask the condition. If the user was unconfirmed, the stored password
is replaced with a secure random string that satisfies the password
policy. This applies to both the web and mobile OAuth callback paths, as
well as the sign-up path where the password is rotated before the reset
token is generated.
Users who lose access to password-based login as a side effect can
recover through the standard "Forgot password" flow at any time. Since
they've already proven email ownership via OAuth, this is a low-friction
recovery path
# Pull Request Template
## Description
This PR fixes an issue where markdown tables were not rendering
correctly in the Help Center.
The issue was caused by a backslash `(\)` being appended after table row
separators `(|)`, which breaks the markdown table parsing.
The issue was introduced after recent editor changes made to preserve
new lines, which unintentionally affected how table markdown is parsed
and displayed.
### https://github.com/chatwoot/prosemirror-schema/pull/44
Fixes
https://linear.app/chatwoot/issue/CW-6714/markdown-tables-dont-render-properly-in-help-centre-preview
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
**Before**
```
| Type | What you provide |\
|--------------|-------------------------------|\
| None | No authentication |\
| Bearer Token | A token string |\
| Basic Auth | Username and password |\
| API Key | A custom header name and value|
```
**After**
```
| Type | What you provide |
|--------------|-------------------------------|
| None | No authentication |
| Bearer Token | A token string |
| Basic Auth | Username and password |
| API Key | A custom header name and value|
```
## 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
## Summary
- Add `MutexApplicationJob::LockAcquisitionError` to Sentry's
`excluded_exceptions`
- This error is expected control flow (mutex lock contention during
webhook processing), not a bug
- Generated ~131K Sentry events in March 2026, 100% from
`InstagramEventsJob`
Fixes https://linear.app/chatwoot/issue/INF-58
Images and videos sent from Chatwoot to LINE inboxes fail to display on
the LINE mobile app — users see expired markers, broken thumbnails, or
missing images. This happens because LINE mobile lazy-loads images
rather than downloading them immediately, and the ActiveStorage signed
URLs expire after 5 minutes.
Closes
https://linear.app/chatwoot/issue/CW-6696/line-messaging-with-image-or-video-may-not-show-when-client-inactive
## How to reproduce
1. Create a LINE inbox and start a chat from the LINE mobile app
2. Close the LINE mobile app
3. Send an image from Chatwoot to that chat
4. Wait 7-8 minutes (past the 5-minute URL expiration)
5. Open the LINE mobile app — the image is broken/expired
## What changed
- **`originalContentUrl`**: switched from `download_url` (signed, 5-min
expiry) to `file_url` (permanent redirect-based URL)
- **`previewImageUrl`**: switched to `thumb_url` (250px resized
thumbnail meeting LINE's 1MB/240x240 recommendation), with fallback to
`file_url` for non-image attachments like video
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
## Linear Ticket
https://linear.app/chatwoot/issue/CW-6707/socketresolutionerror-failed-to-open-tcp-connection-to-permanentlyhttps://linear.app/chatwoot/issue/CW-6707/socketresolutionerror-failed-to-open-tcp-connection-to-permanently#comment-14e0f9ff
## Description
Browser push notifications fail with Socket::ResolutionError when the
push subscription endpoint's domain can't be resolved via DNS (e.g.,
defunct push service, transient DNS failure). This error wasn't handled
in handle_browser_push_error, so it fell through to the catch-all else
branch and got reported to Sentry on every notification attempt — 1,637
times in the last 7 days.
The dead subscription was never cleaned up or the error suppressed, so
every subsequent notification for the affected user triggered the same
Sentry alert.
Added Socket::ResolutionError to the existing transient network error
handler alongside Errno::ECONNRESET, Net::OpenTimeout, and
Net::ReadTimeout. The error is logged but not reported to Sentry, and
the subscription is kept intact in case it's a temporary DNS blip.
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Verified that Socket::ResolutionError is a subclass of StandardError
and matches the when clause
## 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: Vishnu Narayanan <iamwishnu@gmail.com>
# Pull Request Template
## Description
This PR fixes the white background bleed visible in the widget, widget
article viewer and help center when dark mode is active.
**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
**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
# Pull Request Template
## Description
The initial version of prompt deciding to resolve or hand-off to human
agents was too conservative especially in cases where a link or an
action was told to customer. If the customer didn't respond, Captain was
told to hand it off to the agent, but customer may actually have solved
the issue. If not, they can come back and continue the conversation.
Removed two lines about the same and now we should not see needless
handoffs.
## 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
This PR fixes
1. Messages being trimmed to the default 1024 limit in `trimContent`
method, instead of channel-specific limits for drafts and AI tasks.
2. Telegram messages are allowed up to 10,000 characters in config, but
the API supports only 4096, causing failures for oversized messages.
Fixes
https://linear.app/chatwoot/issue/CW-6694/captain-ai-rewrite-tasks-truncate-draft-to-1024-chars-trimcontenthttps://github.com/chatwoot/chatwoot/issues/13919
## 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/00e9d6b4d19247febf35dffa99da3805
**After**
https://www.loom.com/share/c4900e9effc345c79bcd8a5aa1ee277b
## 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
Repurpose the deprecated response_bot feature flag slot for
custom_tools.
Migration disables the flag on any accounts that had response_bot
enabled so the repurposed slot starts in its default-off state.
Pre-deploy: run the disable script on production using the old flag name
(response_bot) before deploying this migration.
Adds `WebsiteBrandingService` (OSS) with an Enterprise override using
Firecrawl v2 to extract branding and business data from a URL for
onboarding auto-fill.
OSS version uses HTTParty + Nokogiri to extract:
- Business name (og:site_name or title)
- Language (html lang)
- Favicon
- Social links from `<a>` tags
Enterprise version makes a single Firecrawl call to fetch:
- Structured JSON (name, language, industry via LLM)
- Branding (favicon, primary color)
- Page links
Falls back to OSS if Firecrawl is unavailable or fails.
Social handles (WhatsApp, Facebook, Instagram, Telegram, TikTok, LINE)
are parsed deterministically via a shared `SocialLinkParser`.
> We use links for socials, since the LLM extraction was unreliable,
mostly returned empty, and hallucinated in some rare scenarios
## How to test
```ruby
# OSS (no Firecrawl key needed)
WebsiteBrandingService.new('chatwoot.com').perform
# Enterprise (requires CAPTAIN_FIRECRAWL_API_KEY)
WebsiteBrandingService.new('notion.so').perform
WebsiteBrandingService.new('postman.com').perform
```
Verify the returned hash includes business_name, language,
industry_category, social_handles, and branding with
favicon/primary_color.
<img width="908" height="393" alt="image"
src="https://github.com/user-attachments/assets/e3696887-d366-485a-89a0-8e1a9698a788"
/>
## Description
When a customer downgrades from Enterprise to Business, they may retain
unused Stripe credit balance. During an AI credits topup,
Stripe::Invoice.finalize_invoice auto-applies that credit balance to the
invoice. If the credit balance fully covers the invoice amount, Stripe
marks it as paid immediately upon finalization. Calling
Stripe::Invoice.pay on an already-paid invoice throws an error, breaking
the topup flow.
This fix retrieves the invoice status after finalization and skips the
pay call if Stripe has already settled it via credits.
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Tested against Stripe test mode with the following scenarios:
- Full credit balance payment: Customer has enough Stripe credit balance
to cover the entire invoice. Invoice is marked paid after
finalize_invoice — pay is correctly skipped. Credits are fulfilled
successfully.
- Partial credit balance payment: Customer has some Stripe credit
balance but not enough to cover the full amount. Invoice remains open
after finalization — pay is called and charges the remaining amount to
the default payment method. Credits are fulfilled successfully.
- Zero credit balance (normal payment): Customer has no Stripe credit
balance. Invoice remains open after finalization — pay charges the full
amount. Credits are fulfilled successfully.
## 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
Adds Estonian to the settings language dropdown so accounts can select
the existing `et` translation from the UI.
Fixes: N/A
Closes: N/A
## Why
Estonian translation files already exist in the repo and in Crowdin, but
the settings dropdown is driven by `LANGUAGES_CONFIG`, where `et` was
missing.
## What this change does
- Adds `et` / `Eesti (et)` to `LANGUAGES_CONFIG`
- Makes Estonian available in the settings language selectors backed by
`enabledLanguages`
## Validation
- `ruby -c config/initializers/languages.rb`
- Opened the local UI at `/app/accounts/1/settings/general` and verified
`Eesti (et)` appears in the `Site language` dropdown
---------
Co-authored-by: Pranav <pranav@chatwoot.com>
## Description
When Assignment V2 is enabled, the V2 capacity policies
(AgentCapacityPolicy / InboxCapacityLimit) are not respected during
team-based assignment paths. The system falls back to the legacy V1
max_assignment_limit, and since V1 is deprecated and typically
unconfigured in V2 setups, agents receive unlimited assignments
regardless of their V2 capacity.
Root cause: Inbox class directly defined
member_ids_with_assignment_capacity, which shadowed the
Enterprise::InboxAgentAvailability module override in Ruby's method
resolution order (MRO). This made the V2 capacity check unreachable
(dead code) for any code path using member_ids_with_assignment_capacity.
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
⏺ Before the fix
1. Enable assignment_v2 + advanced_assignment on account
2. Create AgentCapacityPolicy with InboxCapacityLimit = 1 for an inbox
3. Assign the policy to an agent (e.g., John)
4. Create 1 open conversation assigned to John (now at capacity)
5. Create a new unassigned conversation in the same inbox
6. Assign a team (containing John) to that conversation
7. Result: John gets assigned despite being at capacity
⏺ After the fix
Same steps 1–6.
7. Result: John is NOT assigned — conversation stays unassigned (no
agents with capacity available)
## 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
This ignores `CVE-2026-33658` in `bundler-audit` after validating that
Chatwoot's default and recommended storage setups do not use Active
Storage proxy mode.
Fixes: N/A
Closes: N/A
## Why
`CVE-2026-33658` is an Active Storage proxy-mode DoS issue triggered by
multi-range requests.
For Chatwoot, the default and recommended setups do not appear to route
file downloads through Rails proxy mode:
- `config/environments/production.rb` selects the Active Storage service
but does not opt into `rails_storage_proxy`
- `.env.example` defaults to `ACTIVE_STORAGE_SERVICE=local`
- Chatwoot's storage docs recommend local/cloud storage with optional
direct uploads to the storage provider
- existing specs expect redirect/disk-style Active Storage URLs rather
than proxy-mode URLs
Given that validation, ignoring this advisory is a smaller and more
accurate response than a framework-wide Rails upgrade.
## What this change does
- adds `.bundler-audit.yml`
- preserves the existing advisory ignore entries already used by
Chatwoot
- ignores `CVE-2026-33658`
- documents why the ignore is acceptable for Chatwoot's current defaults
- notes that this should be revisited if Chatwoot enables
`rails_storage_proxy` or other app-served Active Storage proxy routes
## Validation
- reviewed `config/environments/production.rb`
- reviewed `.env.example`
- reviewed Chatwoot storage docs:
https://developers.chatwoot.com/self-hosted/deployment/storage/s3-bucket
- reviewed Active Storage URL expectations in
`spec/controllers/slack_uploads_controller_spec.rb` and
`spec/services/line/send_on_line_service_spec.rb`
- ran `bundle exec bundle-audit check --no-update`
This change blocks Help Center access for default/Hacker-plan accounts
and closes the downgrade gap that could leave `help_center` enabled
after a subscription falls back to the default cloud plan.
Fixes: none
Closes: none
## Why
Default-plan accounts should not be able to access the Help Center, but
the downgrade fallback path only reset the plan name and did not
reconcile premium feature flags. That meant some accounts could keep
`help_center` enabled even after landing back on the Hacker/default
plan.
## What this change does
- blocks Help Center portal and article access for default/Hacker-plan
accounts
- reconciles premium feature flags when a subscription falls back to the
default cloud plan, so `help_center` is disabled immediately instead of
waiting for a later webhook
- preserves existing account `custom_attributes` during Stripe customer
recreation instead of overwriting them
- adds Enterprise coverage for the default-plan access checks on hosted
and custom-domain Help Center routes
- fixes the public access check to use the resolved portal object so
blocked requests return the intended response instead of raising an
error
## Validation
1. Create or use an account on the default/Hacker cloud plan with an
active portal.
2. Visit the portal home page and a published article on both the
Chatwoot-hosted URL and a configured custom domain.
3. Confirm the Help Center is blocked for that account.
4. Downgrade a paid account back to the default/Hacker plan through the
Stripe webhook flow.
5. Confirm `help_center` is disabled right after the downgrade fallback
is processed and the account can no longer access the Help Center.
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
# Pull Request Template
## Description
This PR upgrades the ProseMirror editor and enables automatic URL
linkification on paste. Previously, URLs were only linkified after a
user input event (e.g., typing a space). With this change, URLs are now
linkified instantly when pasted.
Fixes
https://linear.app/chatwoot/issue/CW-6682/email-channel-links-are-not-working
### https://github.com/chatwoot/prosemirror-schema/pull/42
## 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/d38725c9-a152-4c2c-8c33-3ee717f1628f
**After**
https://github.com/user-attachments/assets/9a69a0b6-93ee-421e-896b-5a4e01a167ba
## 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
# Pull Request Template
## Description
This PR fixes the link formatting issue on the backend by adding
`:autolink` to `ChatwootMarkdownRenderer#render_message`, ensuring all
URLs are converted to `<a>` tags.
Fixes
https://linear.app/chatwoot/issue/CW-6682/email-channel-links-are-not-working
## 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
- [ ] 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: Sojan Jose <sojan@pepalo.com>
issue: https://github.com/chatwoot/chatwoot/issues/13921
Fix a typo in the Messages API operationId where `converation` was used
instead of `conversation`. This causes cspell errors when generating
client code with tools like Orval.
## What changed
- `swagger/paths/public/inboxes/messages/index.yml`: fixed operationId
from `list-all-converation-messages` to `list-all-conversation-messages`
- `swagger/swagger.json` and `swagger/tag_groups/client_swagger.json`:
regenerated to reflect the fix
## Note
If you are using a code generator like Orval against this swagger spec,
the generated function name will change from
`listAllConverationMessages` to `listAllConversationMessages`.
Self-hosted installations were incorrectly hitting the daily email rate
limit of 100, seeded from `installation_config`. Since self-hosted users
control their own infrastructure, email rate limiting should only apply
to Chatwoot Cloud.
Closes#13913
After a successful WhatsApp OAuth reauthorization, the health check runs
immediately and finds the phone number in a pending provisioning state
(`platform_type: NOT_APPLICABLE`). This incorrectly triggers
`prompt_reauthorization!`, re-setting the Redis disconnect flag and
sending a disconnect email — even though the reauth just succeeded.
The fix skips the health check during reauthorization flows. It still
runs for new channel creation.
Closes https://github.com/chatwoot/chatwoot/pull/12556
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How to reproduce
1. Have a WhatsApp channel with a phone number in pending provisioning
state (display name not yet approved by Meta)
2. Complete the OAuth reauthorization flow
3. Observe that the user receives a "success" response but immediately
gets a disconnect email
## What changed
- `Whatsapp::EmbeddedSignupService#perform` now skips
`check_channel_health_and_prompt_reauth` when `inbox_id` is present
(reauthorization flow)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
When syncing contacts to LeadSquared, the `Lead.CreateOrUpdate` API
defaults to searching by email. If a contact has no email (or a
different email) but a phone number matching an existing lead, the API
fails with `MXDuplicateEntryException` instead of finding and updating
the existing lead. This accounted for ~69% of all LeadSquared
integration errors, and cascaded into "Lead not found" failures when
posting transcript and conversation activities (~14% of errors).
## What changed
- `LeadClient#create_or_update_lead` now catches
`MXDuplicateEntryException` and retries the request once with
`SearchBy=Phone` appended to the body, telling the API to match on phone
number instead
- Once the retry succeeds, the returned lead ID is stored on the contact
(existing behavior), so all future events use the direct `update_lead`
path and never hit the duplicate error again
## How to reproduce
1. Create a lead in LeadSquared with phone number `+91-75076767676` and
email `a@example.com`
2. In Chatwoot, create a contact with the same phone number but a
different email (or no email)
3. Trigger a contact sync (via conversation creation or contact update)
4. Before fix: `MXDuplicateEntryException` error in logs, contact fails
to sync
5. After fix: retry with `SearchBy=Phone` finds and updates the existing
lead, stores the lead ID on the contact
# 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.
- Add language_code setting to Dialogflow integration configuration
- Support 'auto' mode to detect language from contact's
additional_attributes
- Fallback to 'en-US' when no language is configured or detected
- Include comprehensive language options (22 languages)
- Add tests for language code configuration scenarios
Fixes#3071
## 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?
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.
<img width="815" height="506" alt="Screenshot 2026-01-10 220410"
src="https://github.com/user-attachments/assets/26d2619c-ed42-4c9a-a41d-9fb07ef91a30"
/>
## 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: Sojan Jose <sojan@pepalo.com>
This pull request fixes the model annotation tooling due to previous
incomplete migration from `annotate` to `annotaterb` gem (#12600). It
also improves the handling of serialized values in the
`InstallationConfig` model by ensuring a default value is set,
simplifying the code, and removing a workaround for YAML
deserialization.
**Annotation tooling updates:**
* Added `.annotaterb.yml` to configure the `annotate_rb` gem with
project-specific options, centralizing annotation settings.
* Replaced the custom `auto_annotate_models.rake` task with the standard
rake task from `annotate_rb`, and added `lib/tasks/annotate_rb.rake` to
load annotation tasks in development environments.
[[1]](diffhunk://#diff-9450d2359e45f1db407b3871dde787a25d60bb721aed179a65ffd2692e95fb4bL1-L61)
[[2]](diffhunk://#diff-578cdfc7ad56637e42472ea891ea286dff8803d9a1750afdbfeafec164d9b8b2R1-R8)
**Model serialization improvements:**
* Updated the `InstallationConfig` model to set a default value for the
`serialized_value` attribute, ensuring it always has a hash with
indifferent access and removing the need for a deserialization
workaround in the `value` method.
[[1]](diffhunk://#diff-b4bdde42c1ad0f584073818bd43dbd865b1b3b50d4701b131979f900d7c68297L22-R22)
[[2]](diffhunk://#diff-b4bdde42c1ad0f584073818bd43dbd865b1b3b50d4701b131979f900d7c68297L36-L39)
---------
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Adds a Platform API endpoint that allows migrating existing Google and
Microsoft email channels (with OAuth credentials) into Chatwoot without
requiring end-users to re-authenticate. This enables customers who lack
Rails console access to programmatically migrate email channels from
legacy systems.
### How to test
1. Create a Platform App and grant it permissible access to a target
account
2. `POST /platform/api/v1/accounts/:account_id/email_channel_migrations`
with a payload like:
```json
{
"migrations": [
{
"email": "support@example.com",
"provider": "google",
"provider_config": {
"access_token": "...",
"refresh_token": "...",
"expires_on": "..."
},
"inbox_name": "Migrated Support"
}
]
}
```
3. Verify channels are created with correct provider, provider_config, and IMAP defaults
4. Verify partial failures (e.g. duplicate email) don't roll back other migrations in the batch
5. Verify unauthenticated and non-permissible requests return 401
---------
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Webhook payloads (`message_created`, `message_updated`) started sending
channel-rendered HTML in the `content` field instead of the original raw
message content after PR #12878. This broke downstream agent bots and
integrations that expected plain text or markdown.
Closes https://linear.app/chatwoot/issue/PLA-109/webhook-payloads-send-channel-rendered-html-instead-of-raw-content
## How to reproduce
1. Connect an agent bot to a WebWidget inbox
2. Send a message with markdown formatting (e.g. `**bold**`) from the
widget
3. Observe the agent bot webhook payload — `content` field contains
`<p><strong>bold</strong></p>` instead of `**bold**`
## What changed
Split `MessageContentPresenter` into two public methods:
- `outgoing_content` — renders markdown for the target channel (used by
channel delivery services)
- `webhook_content` — returns raw content with CSAT survey URL when
applicable, no markdown rendering (used by `webhook_data`)
Updated `Message#webhook_data` to use `webhook_content` instead of
`outgoing_content`.
🤖 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>
## Summary
- Adds `LimitNOFILE=65536` to both web and worker systemd service units
- Fixes recurring `Errno::EMFILE` (Too many open files) errors during
peak traffic after deploys
## Context
Puma workers idle at 720-770 open FDs against the default soft limit of
1024, leaving ~250 FDs of headroom. During deploy-triggered instance
refreshes at peak traffic, concurrent requests exhaust the remaining
FDs, causing EMFILE across all web instances.
3 incidents in March 2026 with escalating event counts. The hard limit
is already 524288, so this just raises the soft limit to a standard
production value.
Self-hosted instances pick this up automatically via `cwctl --upgrade`.
Fixes
https://linear.app/chatwoot/issue/CW-6685/errnoemfile-too-many-open-files-rb-sysopen
# Pull Request Template
## Description
Relocate controller from enterprise/ to app/ and add
Api::V1::Accounts::Captain::TasksController.prepend_mod_with for EE
overrides.
Fixes: Ai assist giving 404 on CE
## 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.
before:
<img width="482" height="130" alt="image"
src="https://github.com/user-attachments/assets/f51dc28a-ac54-45c4-9015-6f956fdf5057"
/>
after:
<img width="458" height="182" alt="image"
src="https://github.com/user-attachments/assets/eb86a679-5482-4157-9f4e-f3e9953d8649"
/>
## 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: Shivam Mishra <scm.mymail@gmail.com>
# Pull Request Template
## Description
This PR fixes the conversation list showing raw "**in less than a
minute**" text instead of "**now**" for very recent conversations.
Fixes https://linear.app/chatwoot/issue/CW-6666/issue-with-timestamps
## 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
- [ ] 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
Webhook retry failure logs for agent-bot now include the event payload,
making it easier to identify which event failed when debugging transient
upstream errors (429/500).
Previously the log only showed:
`[AgentBots::WebhookJob] attempt 1 failed
RestClient::InternalServerError`
Now it includes the payload:
`[AgentBots::WebhookJob] attempt 1 failed
RestClient::InternalServerError payload={"event":"message_created",...}`
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Pull Request Template
## Description
- Skip handoff when the conversation is no longer pending.
- Fetch conversation status with Conversation.uncached to avoid stale
query cache when checking pending state.
## 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.
Difficult to reproduce locally, but seen a couple of conversations where
bot hands off an extra time or Captain re-generates usually due to a
retry on FaradayTimeoutError but at perform time, the conversation
status is stale so it appears as if Captain responded in an open
conversation.
## 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
For our account, the conversation completion evaluator was proving to be
too conservative. This resulted in queue noise.
This PR adds a line to handle gibberish/single worded messages with no
further context.
## 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.
<img width="3012" height="978" alt="CleanShot 2026-03-23 at 11 44 55@2x"
src="https://github.com/user-attachments/assets/328195e8-6ea0-4c3a-9049-ee80196eecad"
/>
<img width="2202" height="866" alt="CleanShot 2026-03-23 at 11 47 15@2x"
src="https://github.com/user-attachments/assets/8d51cff4-b18f-4582-9455-8119ec7eff3a"
/>
## 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 support to auto-focus the editor when clicking reply to
this message, the editor now automatically receives focus so users can
start typing immediately.
Fixes
https://linear.app/chatwoot/issue/CW-6661/typing-box-not-focused-after-clicking-reply-to-message
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Screencast
https://github.com/user-attachments/assets/c5e77055-3f68-4ad8-934e-cfc465166e8a
## 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
## Description
The RemoveOrphanConversationsService filters orphan conversations by a
time window before deleting them. Previously it used created_at, which
could miss old conversations that still had recent activity.
Switching to last_activity_at ensures the cleanup window reflects actual
conversation activity rather than creation time.
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- By running Rake task
- Run the job from console
## 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
# Pull Request Template
## Description
Captain v1 does not have access to contact attributes. Added a toggle to
let user choose if they want contact information available to Captain.
## 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.
Specs and locally
<img width="1924" height="740" alt="CleanShot 2026-03-19 at 18 48 19@2x"
src="https://github.com/user-attachments/assets/353cfeaa-cd58-40eb-89e7-d660a1dc1185"
/>
![Uploading CleanShot 2026-03-19 at 18.53.26@2x.png…]()
## 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
## Description
Add missing JSON imports and spread exports for `companies`,
`contentTemplates`, `mfa`, `snooze`, `webhooks`, and `yearInReview` so
these translations are properly loaded in the pt_BR locale. Without
these imports, those sections of the UI were falling back to English for
Brazilian Portuguese users.
## 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] My changes generate no new warnings
Co-authored-by: Sojan Jose <sojan@pepalo.com>
## PR#1: Reporting events rollup — model and write path
Reporting queries currently hit the `reporting_events` table directly.
This works, but the table grows linearly with event volume, and
aggregation queries (counts, averages over date ranges) get
progressively slower as accounts age.
This PR introduces a pre-aggregated `reporting_events_rollups` table
that stores daily per-metric, per-dimension (account/agent/inbox)
totals. The write path is intentionally decoupled from the read path —
rollup rows are written inline from the event listener via upsert, and a
backfill service exists to rebuild historical data from raw events.
Nothing reads from this table yet.
The write path activates when an account has a `reporting_timezone` set
(new account setting). The `reporting_events_rollup` feature flag
controls only the future read path, not writes — so rollup data
accumulates silently once timezone is configured. A `MetricRegistry`
maps raw event names to rollup column semantics in one place, keeping
the write and (future) read paths aligned.
### What changed
- Migration for `reporting_events_rollups` with a unique composite index
for upsert
- `ReportingEventsRollup` model
- `reporting_timezone` account setting with IANA timezone validation
- `MetricRegistry` — single source of truth for event-to-metric mappings
- `RollupService` — real-time upsert from event listener
- `BackfillService` — rebuilds rollups for a given account + date from
raw events
- Rake tasks for interactive backfill and timezone setup
- `reporting_events_rollup` feature flag (disabled by default)
### How to test
1. Set a `reporting_timezone` on an account
(`Account.first.update!(reporting_timezone: 'Asia/Kolkata')`)
2. Resolve a conversation or trigger a first response
3. Check `ReportingEventsRollup.where(account_id: ...)` — rows should
appear
4. Run backfill: `bundle exec rake reporting_events_rollup:backfill` and
verify historical data populates
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
## Description
This PR updates the OpenAPI schema definitions for Public API resources
(`public_conversation`, `public_message`, `public_contact`) so they
match the actual API responses produced by the jbuilder views.
These definitions were introduced in #2417 (2021-06) with a minimal set
of fields. The jbuilder views have since been updated (e.g. `uuid` in
#7255, `agent_last_seen_at` in #4377), but the Swagger definitions were
never updated. As a result, generated API clients get incorrect or
missing types. This change fixes that by aligning the schemas with the
implementation.
**Fixes #13692**
## 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)
- [x] This change requires a documentation update
## How Has This Been Tested?
- Compared each jbuilder view
(`app/views/public/api/v1/models/_conversation.json.jbuilder`,
`_message.json.jbuilder`, `_contact.json.jbuilder`) field-by-field
against the Swagger YAML definitions.
- Cross-referenced Ruby model enums (`Conversation.status`,
`Attachment.file_type`, `Message.message_type`) for enum values.
- Ran the swagger build (via the project’s `rake swagger:build` logic /
`json_refs` resolution) to regenerate `swagger.json` and tag group
files; confirmed the generated schemas contain the correct fields and
types.
- No runtime tests were run; this is a documentation/schema-only change.
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas *(N/A: schema definitions)*
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works *(N/A: documentation/schema only)*
- [ ] New and existing unit tests pass locally with my changes *(N/A: no
code under test)*
- [x] Any dependent changes have been merged and published in downstream
modules *(N/A: none)*
---
## Change summary (for reference)
### `public_conversation`
- Added missing fields: `uuid`, `status`, `contact_last_seen_at`,
`agent_last_seen_at`
- Fixed `inbox_id` type from `string` to `integer`
- Fixed `messages` items `$ref` from `message` to `public_message`
- Added property details for embedded `contact` object (`id`, `name`,
`email`, `phone_number`)
- Added `status` enum: `open`, `resolved`, `pending`, `snoozed`
### `public_message`
- Fixed `id`, `message_type`, `created_at`, `conversation_id` types
(string → integer where applicable)
- Fixed `content_attributes` type from `string` to `object`
- Added property details for `attachments` items and `sender` object
- Added `file_type` and `sender.type` enums
### `public_contact`
- Added missing `phone_number` field
---------
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Devise 4.9.x has a race condition in the reconfirmable flow where
concurrent email change requests can desynchronize the confirmation
token from `unconfirmed_email`, letting an attacker confirm an email
they don't own. We use `:confirmable` with `reconfirmable = true`, so
we're directly exposed.
The upstream fix is in Devise 5.0.3, but we can't upgrade —
`devise-two-factor` only supports Devise 5 from v6.4.0, which also
raised its Rails minimum to 7.2+. No released version supports both
Devise 5 and Rails 7.1.
This PR ports the Devise 5.0.3 fix locally by overriding
`postpone_email_change_until_confirmation_and_regenerate_confirmation_token`
on the User model to persist the record before regenerating the token.
This is a stopgap — remove it once the dependency chain allows upgrading
to Devise 5.
### How to test
Sign in as a confirmed user and change your email. The app should send a
confirmation to the new address while keeping the current email
unchanged until confirmed.
The Platform API was returning the bot's `name` value for the
`outgoing_url` field across all agent bot endpoints (index, show,
create, update). A typo in the `_agent_bot.json.jbuilder` partial used
`resource.name` instead of `resource.outgoing_url`.
Closes#13787
## What changed
- `app/views/platform/api/v1/models/_agent_bot.json.jbuilder`: corrected
`resource.name` → `resource.outgoing_url` on the `outgoing_url` field.
## How to reproduce
1. Create an agent bot via `POST /platform/api/v1/agent_bots` with a
distinct `outgoing_url`.
2. Call `GET /platform/api/v1/agent_bots/:id`.
3. Before this fix the `outgoing_url` in the response equals the bot's
`name`; after the fix it equals the value set at creation.
## Tests
Added `outgoing_url` assertions to the existing GET index and GET show
request specs so the regression is covered.
# Pull Request Template
## Description
we were getting 403, 401 errors on `translate_query` on langfuse and
sentry
This happened because, we use the customer's openai key if they have
BYOK
But translation is something they never opt in so we should not use
their quota for it.
This PR addresses the issue.
## 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
When `allowed_domains` is configured on a web widget inbox, the server
responds with Content-Security-Policy: frame-ancestors <domains>, which
blocks the widget iframe in mobile app WebViews. This happens because
WebViews load content from file:// or null origins, which cannot match
any domain in the frame-ancestors directive.
This adds a per-inbox toggle — "Enable widget in mobile apps" — that
skips the frame-ancestors header when the request has no valid Origin
(i.e., it comes from a mobile WebView). Web browsers with a real origin
still get domain restrictions enforced as usual.
<img width="2330" height="1490" alt="CleanShot 2026-03-11 at 10 13
01@2x"
src="https://github.com/user-attachments/assets/d9326fac-020d-4ce7-9ced-0c185468c8fc"
/>
Fixes
https://linear.app/chatwoot/issue/CW-6560/widget-is-not-loading-from-iosandroid-widgets
How to test
1. Go to Settings → Inboxes → (Web Widget) → Configuration
2. Set allowed_domains to a specific domain (e.g., *.example.com)
3. Try loading the widget in a mobile app WebView — it should be blocked
4. Enable "Enable widget in mobile apps" checkbox
5. Reload the widget in the WebView — it should now load successfully
6. Verify the widget on a website not in the allowed domains list is
still blocked
---------
Co-authored-by: iamsivin <iamsivin@gmail.com>
This change spreads Chatwoot Hub version checks across the day by
scheduling each installation at a stable minute derived from its
installation identifier, instead of having all instances check at the
same fixed time.
Closes
-
https://linear.app/chatwoot/issue/CW-6107/handle-the-spike-at-12-utc-on-chatwoot-hub
What changed
- Added `Internal::TriggerDailyScheduledItemsJob` to act as the daily
trigger for deferred internal jobs.
- Updated the version check cron entry to run once daily at `00:00 UTC`
and enqueue the actual version check for that installation’s assigned
minute of the day.
- Used a deterministic minute-of-day derived from
`ChatwootHub.installation_identifier` so the check time stays stable
across deploys and restarts.
- Kept the existing cron schedule key while switching it to the new
orchestrator job.
How to test
- Run `bundle exec rspec
spec/jobs/internal/check_new_versions_job_spec.rb
spec/jobs/internal/trigger_daily_scheduled_items_job_spec.rb
spec/configs/schedule_spec.rb`
- In a Rails console, run
`Internal::TriggerDailyScheduledItemsJob.perform_now` and verify
`Internal::CheckNewVersionsJob` is enqueued with a `wait_until` later
the same UTC day.
- In Super Admin settings, use Refresh and verify the version check
still runs immediately.
---------
Co-authored-by: Sojan Jose <sojan@pepalo.com>
This adds a draft status for Help Center locales so teams can prepare
localized content in the dashboard without exposing those locales in the
public portal switcher until they are ready to publish.
Fixes: https://github.com/chatwoot/chatwoot/issues/10412
Closes: https://github.com/chatwoot/chatwoot/issues/10412
## Why
Teams need a way to work on locale-specific Help Center content ahead of
launch. The public portal should only show ready locales, while the
admin dashboard should continue to expose every allowed locale for
ongoing article and category work.
## What this change does
- Adds `draft_locales` to portal config as a subset of `allowed_locales`
- Hides drafted locales from the public portal language switchers while
keeping direct locale URLs working
- Keeps drafted locales fully visible in the admin dashboard for article
and category management
- Adds locale actions to move an existing locale to draft, publish a
drafted locale, and keep the default locale protected from drafting
- Adds a status dropdown when creating a locale so new locales can be
created as `Published` or `Draft`
- Returns each admin locale with a `draft` flag so the locale UI can
reflect the public visibility state
## Validation
- Seed a portal with multiple locales, draft one locale, and confirm the
public portal switcher hides it while `/hc/:slug/:locale` still loads
directly
- In the admin dashboard, confirm drafted locales still appear in the
locale list and remain selectable for articles and categories
- Create a new locale with `Draft` status and confirm it stays out of
the public switcher until published
- Move an existing locale back and forth between `Published` and `Draft`
and confirm the public switcher updates accordingly
## Demo
https://github.com/user-attachments/assets/ba22dc26-c2e7-463a-b1f5-adf1fda1f9be
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Fixes help center public article search so query responses stay compact
and locale-scoped. Whitespace-only queries are now treated as empty in
both the portal UI and the server-side search path, and search
suggestions stay aligned with the trimmed query.
Fixes: https://github.com/chatwoot/chatwoot/issues/10402
Closes: https://github.com/chatwoot/chatwoot/issues/10402
## Why
The public help center search endpoint reused the full article
serializer for query responses, which returned much more data than the
search suggestions UI needed. That made responses heavier than necessary
and also surfaced nested portal and category data that made the results
look cross-locale.
Whitespace-only searches could also still reach the backend search path,
and in Enterprise that meant embedding search could be invoked for a
blank query.
## What changed
- return a compact search-specific payload for article query responses
- keep the existing full article serializer for normal article listing
responses
- preserve current-locale search behavior for the portal search flow
- trim whitespace-only search terms on the client so they do not open
suggestions or trigger a request
- reuse the normalized query on the backend so whitespace-only requests
are treated as empty searches in both OSS and Enterprise paths
- pass the trimmed search term into suggestions so highlighting matches
the actual query being sent
- add request and frontend regression coverage for compact payloads,
locale scoping, and whitespace-only search behavior
## Validation
1. Open `/hc/:portal/:locale` in the public help center.
2. Enter only spaces in the search box and confirm suggestions do not
open.
3. Search for a real term and confirm suggestions appear.
4. Verify the results are limited to the active locale.
5. Click a suggestion and confirm it opens the correct article page.
6. Inspect the query response and confirm it returns the compact search
payload instead of the full article serializer.
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Email inbox replies now work for Google and Microsoft OAuth inboxes even
when the self-hosted instance does not have global SMTP configured. This
keeps agent replies working for email channels that already have valid
inbox-level delivery settings.
fixes: chatwoot/chatwoot#13118closes: chatwoot/chatwoot#13118
## Why
Self-hosted email inbox replies were blocked by a global SMTP guard in
the `email_reply` path. For OAuth-backed email inboxes, outbound
delivery is configured at the inbox level, so the mailer returned early
and the reply flow failed before sending.
## What this change does
- Allows the `email_reply` path to proceed when the inbox has SMTP
configured
- Allows the `email_reply` path to proceed when the inbox has Google or
Microsoft OAuth delivery configured
- Renames the touched mailer helper predicates to `?` methods for
clarity
## Validation
- Configure a Google email inbox on a self-hosted instance without
global `SMTP_ADDRESS`
- Reply from Chatwoot to an existing email conversation
- Confirm the reply is sent through the inbox OAuth SMTP configuration
- Run `bundle exec rspec
spec/mailers/conversation_reply_mailer_spec.rb:595`
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
https://linear.app/chatwoot/issue/CW-6595/vanta-remediate-high-vulnerabilities-identified-in-packages-are
## Description
- Added "rollup": ">=4.59.0" to pnpm.overrides in package.json
- This bumps rollup from 4.52.5 to 4.59.0 (the transitive dep via vite)
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Overall Sanity 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>
## Description
This PR improves the Ukrainian translation for the Chatwoot widget
(`app/javascript/widget/i18n/locale/uk.json`).
Key changes:
- Fixed typo: `Звантажити` → `Завантажити`
- Translated missing English strings
- Improved reply time messages
- Updated day names to match `{day}` usage in `BACK_ON_DAY`
- Improved UX wording in form placeholders
- Fixed typography in `ім’я`
- Improved consistency with other Chatwoot translations
These updates improve readability and correctness of the Ukrainian
widget interface.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Reviewed the updated translations and verified that:
- Ukrainian translations render correctly
- Reply time messages display properly
- `{day}` values work correctly with the `BACK_ON_DAY` message
- Form placeholders appear correctly
- No untranslated English strings remain
## 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>
media_server: cd enterprise/media-server && AUTH_TOKEN=${MEDIA_SERVER_AUTH_TOKEN:-devtoken} RAILS_CALLBACK_URL=http://localhost:3000 PUBLIC_IP=${MEDIA_SERVER_PUBLIC_IP:-127.0.0.1} RECORDINGS_DIR=/tmp/chatwoot-recordings HTTP_PORT=4000 go run ./cmd/server
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
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.