fd9beedb68bbcf6cc033fd2869fd5fd104f2601d
1746
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
36a05097fa |
fix(webhooks): strip trailing newlines from webhook message content (#14272)
The TipTap/ProseMirror editor stores agent messages with trailing paragraph nodes that produce trailing newlines (e.g. \`\n\n\n\`) in the \`content\` field. While Chatwoot's native channel delivery already handles this, webhook payloads and API responses were returning raw content with trailing whitespace — causing visible blank space below messages in every external integration that consumes Chatwoot webhooks (WhatsApp via Evolution API, Telegram bots, custom webhook consumers). Closes #13459 ## Root cause \`Messages::WebhookContentNormalizer\` already strips CommonMark hard line breaks (\`\\\` + newline) for webhook consumers, but it did not strip trailing whitespace. All webhook and API responses flow through this normaliser, so it is the single correct place to apply the fix without touching stored data. ## What changed Added \`.rstrip\` to \`Messages::WebhookContentNormalizer.normalize\`: \`\`\`ruby # before text.gsub(/\\\r?\n/, "\n") # after text.gsub(/\\\r?\n/, "\n").rstrip \`\`\` ## Trade-offs considered | Option | Decision | |---|---| | \`before_save\` on \`Message\` model | Would clean stored data but is a broader change affecting all message creation paths and would require a data migration for existing records. Out of scope for this bug. | | Trim in each channel's send path | DRY violation — many channels, each would need the same patch. | | Fix at normaliser level (chosen) | Single location, only affects webhook/API output, zero risk to stored data or native channel delivery. | **Known limitation:** existing messages in the database still have trailing newlines in storage. They will be delivered correctly through webhooks after this fix, but a follow-up migration could clean stored content if needed. ## How to reproduce 1. Send an agent reply from the Chatwoot UI 2. Inspect the \`content\` field of the outgoing \`message_created\` webhook payload 3. Observe trailing \`\n\n\n\` after the message text After this fix, the \`content\` field is trimmed before delivery. --------- Co-authored-by: Ramalau Debeila <rdebeila@datacentrix.co.za> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> |
||
|
|
87df43bdd0 |
revert: restore conversation unread count feature flag (#14623)
This reverts #14610 so conversation unread counts are again controlled by the `conversation_unread_counts` feature flag across the API, ActionCable broadcasts, notifier/listener paths, and dashboard sidebar fetching. ## Closes - None ## What changed - Restores feature-flag checks for conversation unread count reads and broadcasts. - Restores the dashboard feature flag constant and sidebar/store behavior for disabled unread counts. - Restores the specs that cover disabled-feature behavior. ## How to test - In an account with `conversation_unread_counts` enabled, verify sidebar unread counts are fetched and updated in real time. - Disable `conversation_unread_counts` for the account and verify unread count requests/broadcasts are skipped. |
||
|
|
37eed5de1e |
feat(whatsapp): Add support for voice messages (#14606)
> Reopened from #13613, now from a personal fork (`gabrieljablonski/chatwoot`) so maintainers can push edits — organization-owned forks don't support "Allow edits from maintainers". The previous PR is closed in favor of this one; same commits, same diff. ## Description This PR adds support for sending voice messages (voice notes) through the WhatsApp Cloud API. When agents record audio in Chatwoot, it is now transcoded in the browser from WebM/Opus to OGG/Opus and sent with the `voice: true` flag, so it appears as a native voice note bubble on WhatsApp — not as a file/document attachment. Closes #13283 **Key Changes:** - Added `webmOpusToOgg.js` — a pure JS EBML parser + OGG page builder that remuxes browser-recorded WebM/Opus audio into OGG/Opus entirely client-side, with no server-side dependencies. - Updated `AudioRecorder.vue` to use an explicit `mimeType` hint, proper resource cleanup, and an `AUDIO_EXTENSION_MAP` for correct file extensions. - Renamed `mp3ConversionUtils.js` → `audioConversionUtils.js` and added OGG conversion support via the new remuxer. - Updated `ReplyBox.vue` to request OGG format for WhatsApp channels, pass `isVoiceMessage` per-attachment, and handle recording errors with a user-facing alert. - Updated `MessageBuilder` to read the `is_voice_message` param and persist it in attachment metadata. - Updated `WhatsappCloudService` to: - Normalize `audio/opus` → `audio/ogg` content type on ActiveStorage blobs (works around Marcel gem re-detection). - Send the `voice: true` flag when the attachment is a voice message with `audio/ogg` content type. - Use WhatsApp Cloud API `v24.0` for the attachment endpoint. - Added `AUDIO_CONVERSION_FAILED` i18n key. **How it works:** 1. The browser records audio as WebM/Opus (Chrome/Firefox default). 2. `audioConversionUtils.js` remuxes it to OGG/Opus using the pure-JS `webmOpusToOgg` remuxer — no server transcoding needed. 3. The OGG file is uploaded with `is_voice_message: true` in the form payload. 4. `MessageBuilder` persists `is_voice_message` in the attachment's `meta` hash. 5. `WhatsappCloudService` normalizes the blob content type if needed, then sends the attachment with `voice: true` so WhatsApp renders it as a voice note. ## Type of change - [X] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? 1. Record a voice message in a WhatsApp Cloud conversation. 2. Verify the audio is transcoded to OGG (check file extension in the attachment preview). 3. Verify the message arrives on WhatsApp as a voice note bubble (not a document/file). 4. Send an image or document attachment and verify it still works as before (no `voice` flag). 5. Send a regular (non-voice) audio file and verify it arrives without the voice flag. --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ecd9c26c8c |
feat: Implemented search results page functionality (#11086)
# Pull Request Template
## Description
Implemented search results page functionality. Now you can press "Enter"
to search by term and display results in a results page. Also now you
can link to /hc/{account}/en/search?query=XXXXXX to view search results
for XXXXXX query.
fixes: https://github.com/chatwoot/chatwoot/issues/10945
## Screenshots
Classic layout search results:
<img width="3840" height="2160" alt="classic-results"
src="https://github.com/user-attachments/assets/3bbb3272-33ca-4eb4-b80a-76ed77442088"
/>
Classic layout pagination:
<img width="3840" height="2160" alt="classic-page-two"
src="https://github.com/user-attachments/assets/062b09d3-7c58-4d3b-8611-b94375e7db51"
/>
Classic layout empty search:
<img width="3840" height="2160" alt="no-results"
src="https://github.com/user-attachments/assets/c5e3f47a-cd9a-4e14-ae92-ccba00c89e98"
/>
Documentation layout search results:
<img width="3840" height="2160" alt="documentation-results"
src="https://github.com/user-attachments/assets/9e45d8d9-c975-4589-b6c6-3bc7bb3c588e"
/>
Documentation layout dark theme:
<img width="3840" height="2160" alt="documentation-dark"
src="https://github.com/user-attachments/assets/cdb6ed63-4241-4b32-9f79-7d92ed479fc8"
/>
Plain embedded dark layout:
<img width="3840" height="2160" alt="plain-embedded-dark"
src="https://github.com/user-attachments/assets/7deb02b9-9f24-48fb-8979-a2ecd7002c05"
/>
---------
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
|
||
|
|
88e2661ca6 |
feat(conversations): remove unread count feature flag (CW-7237) (#14610)
## Description Make conversation unread counts always available at runtime by removing account feature checks from the API endpoint, unread-count listener, notifier, and ActionCable broadcast path. Update the dashboard to fetch sidebar unread counts for the active account without checking FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS, and remove the now-unused store clear action that only supported the disabled state. Keep the feature entry in config/features.yml to preserve flag bit order, but mark it enabled and deprecated so fresh installs default to the always-on behavior while feature-management UI hides it. Leave existing installation default rows untouched; no migration is included, so upgraded installs may still store the old flag value but runtime behavior no longer depends on it. Update specs around the new always-on contract and remove obsolete disabled-feature assertions. Fixes # CW-7237 ## 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 specs around the new always-on contract and remove obsolete disabled-feature assertions. Ran specs locally for the changes. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules |
||
|
|
04ac9d3780 |
fix: use UPN for imap_login on Microsoft OAuth callback (#14522)
Outgoing email on a Microsoft email inbox was failing with `535 5.7.3 Authentication unsuccessful` immediately after the user re-authenticated, while incoming (IMAP) continued to work. The root cause is in our OAuth callback: we persist the id_token's `email` claim into `channel.imap_login`, but Microsoft's SMTP AUTH (XOAUTH2) validates the username against the access token's **UPN**, not the mailbox's primary SMTP address or aliases. When those diverge — common in tenants that use one domain for sign-in identities and another for mailbox addresses — SMTP rejects every send. This PR makes `OauthCallbackController#update_channel` prefer `preferred_username` (v2.0) / `upn` (v1.0) from the id_token over `email`, with `email` as the fallback so Google flows are unchanged. ## What changed - `app/controllers/oauth_callback_controller.rb` — extract `imap_login_identity` (default: `users_data['email']`, same as before). `update_channel` now calls it instead of inlining the email claim. No behavioural change in the base controller. - `app/controllers/microsoft/callbacks_controller.rb` — override `imap_login_identity` to return `preferred_username || upn || super`. Provider-specific knowledge stays in the provider subclass; Google's flow is literally untouched. - `spec/controllers/microsoft/callbacks_controller_spec.rb` — adds one example that reproduces the divergent shape (`email: <alias>`, `preferred_username: <upn>`) and asserts `imap_login` lands on the UPN while `channel.email` stays on the mailbox alias. `channel.email` and `find_channel_by_email` still key on the id_token's `email` claim everywhere, so customer-facing From identity and reconnect-by-email matching are unchanged. Behavioural matrix: | Provider / shape | `imap_login` before | `imap_login` after | |-----------------------------------------------------------------|---------------------|--------------------| | Microsoft, `upn == email` (common case) | email | UPN (= email, same string) | | Microsoft, `upn != email` (e.g. UPN on one domain, mailbox alias on another) | alias (broken) | UPN (works) | | Google | email | email (no change, base impl used) | ## Why this happens (Microsoft side, for context) Two facts that together produce the bug — one is documented, the other we verified empirically because Microsoft's docs don't address it: 1. **`email` and `upn` are different claims and can legitimately diverge.** In Entra, the UPN is the sign-in identity; the id_token's `email` claim is the user's mailbox property (which can be a proxy address). For v2.0 tokens (which is what we use — `/common/oauth2/v2.0/token` in `MicrosoftConcern`), the documented "username to sign in as" claim is `preferred_username`. See [ID token claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference). So `users_data['email']` was the wrong source for `imap_login`; tenants where UPN == primary SMTP happened to mask the bug. 2. **Exchange's XOAUTH2 SMTP rejects aliases in the `user=` field, even though IMAP accepts them.** This asymmetry is **not** in [Microsoft's canonical XOAUTH2 doc](https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth) — the doc treats the SASL `user=` field uniformly across IMAP, POP, and SMTP, with only a shared-mailbox carve-out spelled out. We confirmed the SMTP-strict behaviour empirically by running an XOAUTH2 AUTH probe with the same access token: `user=<alias>` returned `535 5.7.3`, `user=<UPN>` returned `235`. Same token, only the `user=` field differed. That's what tied the symptom to claim-shape. Hence the patch: read the documented "sign-in name" claim and persist that, not the customer-facing mailbox address. ## How to reproduce (before the fix) 1. Set up a Microsoft email inbox in a tenant where the user's UPN domain differs from the user's primary SMTP / mailbox domain (e.g. UPN `user@tenant-a.example`, mailbox `User@tenant-b.example` where `tenant-b.example` is a proxy address on the same mailbox). 2. Connect or re-authenticate the inbox through the standard flow. 3. Inspect the channel: ```ruby ch.imap_login # => "User@tenant-b.example" (alias — wrong) token = ch.provider_config['access_token'] JSON.parse(Base64.urlsafe_decode64(token.split('.')[1].then { |s| s + '=' * (-s.length % 4) }))['upn'] # => "user@tenant-a.example" (UPN — what SMTP actually needs) ``` 4. Send a reply. SMTP returns `535 5.7.3 Authentication unsuccessful`. Incoming IMAP continues to work fine. After the fix, `imap_login` is set to the UPN at callback time and SMTP succeeds. ## Notes for review - The id_token decoding path (`users_data`) already exists and is trusted by the rest of the callback — no new attack surface. - Scoped to Microsoft on purpose. A provider-agnostic fallback chain in the base controller would also have worked (Google id_tokens don't carry `preferred_username` / `upn`, so it'd land on email anyway), but keeping Google's code path identical to today removes any risk of an unintended interaction with whatever a future Google update might add to its id_token. Pattern matches the existing `find_channel_by_email` override Google already has. - The [id_token claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference) warns that `preferred_username` is mutable and "can't be used to make authorization decisions." That warning targets apps using the claim as a stable cross-session identifier for app-level authz — we're not. We use it as the SASL `user=` string for SMTP AUTH, validated by Microsoft against the same token it just issued. The claim's mutability matters only if a tenant admin renames a UPN between re-auths, in which case the stored `imap_login` goes stale and SMTP 535s — but the pre-patch code has the identical mutability characteristic on the `email` claim (rename a mailbox's primary SMTP and you get the same stale-then-535). The patch doesn't enlarge that failure surface; it shrinks it, because UPN-vs-alias divergence is now handled rather than always-broken. - Related but out of scope: SMTP failures don't trigger `channel.authorization_error!` today. `ExceptionList::SMTP_EXCEPTIONS` (`lib/exception_list.rb`) only contains `Net::SMTPSyntaxError`; `Net::SMTPAuthenticationError` bubbles unhandled, and `ApplicationMailer#handle_smtp_exceptions` only logs. So a 535 — whether from this bug or any other identity drift — never prompts re-auth in the UI, unlike the IMAP path (`fetch_imap_emails_job` catches `OAuth2::Error` and calls `authorization_error!`). Worth a separate PR; flagging here so we don't pretend stale-identity SMTP errors are self-healing. - The alternative I considered — decoding the access token's `upn` directly — is more correct in principle but relies on Microsoft access tokens being JWTs, which is undocumented behaviour for the `outlook.office.com` audience and contradicts the [docs guidance](https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens) that access tokens are opaque to clients. - **Not addressed here, flagging for follow-up:** existing channels that were re-authenticated before this fix still hold the alias in `imap_login` and will keep 535ing until the user re-auths again. A one-shot rake task could decode the stored access tokens and reconcile, but fixing forward via natural re-auth is lower-risk; let me know if you want the backfill. - Also out of scope: `app/mailers/conversation_reply_mailer_helper.rb` reads `provider_config['access_token']` raw without going through `Microsoft::RefreshOauthTokenService`, while the IMAP path refreshes. That's a real asymmetry but unrelated to this bug (the token here was minutes-old) and worth its own PR. |
||
|
|
3eed8905cc |
fix(facebook): render shared links as fallback attachments (#14554)
Fixes Facebook fallback and shared-post attachments so they render as clickable links in conversations. Closes: - https://github.com/chatwoot/chatwoot/issues/4767 - https://github.com/chatwoot/chatwoot/issues/5327 Why: Facebook can send shared links as `fallback` attachments with a top-level `url`, and shared posts as `share` attachments with the URL under `payload.url`. The current flow either misses the nested URL or treats `share` as downloadable media, so these messages do not render correctly. What changed: - Store Facebook fallback URLs from either `attachment.url` or `attachment.payload.url`. - Treat Facebook `share` attachments as fallback link attachments instead of downloading them as files. - Render fallback attachments in the next message bubble UI as clickable links. How to test: 1. Connect a Facebook inbox. 2. Send a shared link to the page. 3. Send/share a Facebook post to the page. 4. Open the conversation in Chatwoot. 5. Confirm both messages appear as clickable link bubbles. Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> |
||
|
|
f27bbef73b |
feat: show processing status for one-off campaigns (#14592)
## Summary One-off SMS and WhatsApp campaigns now show a `Processing` state while the audience send is in progress. The campaign moves to `Completed` after processing finishes, and already-processing campaigns are skipped by the scheduler to avoid duplicate sends. ## Closes - [CW-6037: feat: Introduce an in-progress status for campaigns](https://linear.app/chatwoot/issue/CW-6037/feat-introduce-an-in-progress-status-for-campaigns) ## Screenshot SMS campaign card showing the new `Processing` status. <img width="3840" height="2160" alt="framed-campaign-processing-status" src="https://github.com/user-attachments/assets/de7913b5-65fb-4121-9034-24a568eb0382" /> ## What changed - Added `processing` as a campaign status. - Mark one-off campaigns as `processing` under a row lock before the send service runs. - Complete SMS, Twilio SMS, and WhatsApp one-off campaigns after audience processing finishes. - Keep campaigns in `processing` if an unexpected service error escapes, so the scheduler does not automatically resend the audience. - Added the `Processing` label for SMS and WhatsApp campaign cards. ## Known operational behavior If a worker is interrupted or an unexpected service error escapes after a campaign is marked `processing`, the campaign can remain in `processing`. This is intentional for now to avoid automatic full-audience resends. Installation admins can decide whether to mark the campaign completed or restart it manually from the Rails console after checking what was sent. ## How to test - Create a one-off SMS or WhatsApp campaign scheduled for now. - Run the scheduled job or trigger the campaign job. - Confirm the campaign card shows `Processing` while the audience is being processed. For small audiences, refresh during processing or use a larger audience so the state is observable. - Confirm the campaign moves to `Completed` after audience processing finishes. - Confirm an already-processing campaign is not enqueued again by the scheduled job. |
||
|
|
1afcd36dee |
fix(contacts): align contact export permissions (#14601)
Allows contact managers to export and import contacts from the Contacts page while keeping plain agents blocked. The contacts action menu now mirrors backend permissions for both export and import. ## Closes - https://linear.app/chatwoot/issue/CW-4438/contact-export-is-broken ## What changed - Allows Enterprise custom roles with `contact_manage` to pass `ContactPolicy#export?` and `ContactPolicy#import?`. - Shows Export and Import to admins and contact managers only. - Adds Enterprise policy coverage for contact export and import. ## Screenshots Admin: Export and Import are available. <img width="3840" height="2160" alt="Admin contact actions with Export and Import visible" src="https://github.com/user-attachments/assets/2b2cdaf2-ca8f-470d-be34-31cba68b9dce" /> Contact manager: Export and Import are available. <img width="3840" height="2160" alt="Contact manager contact actions with Export and Import visible" src="https://github.com/user-attachments/assets/48fc038b-2e78-4d0c-ba17-a5965641bd88" /> Regular agent: Export and Import are hidden. <img width="3840" height="2160" alt="Regular agent contact actions with Export and Import hidden" src="https://github.com/user-attachments/assets/a63b5731-743a-4223-8dab-ce58383067fe" /> ## How to test - Sign in as an administrator and open Contacts; the action menu shows Export and Import. - Sign in as a custom-role user with `contact_manage`; the action menu shows Export and Import. - Sign in as a plain agent; Export and Import are not available and both APIs remain unauthorized. |
||
|
|
a3ffb48a47 |
refactor(onboarding): use separate onboarding controller (#14507)
Depends on: https://github.com/chatwoot/chatwoot/pull/14370 This PR creates a new onboarding controller, this allows more control that the default account update API. Allowing us to spin tasks and update details required specifically during the onboarding flow |
||
|
|
6c8741b314 |
fix: increase audit log page size (#14582)
Audit logs now return up to 25 records per page instead of 15. This reduces page turns for admins and API consumers while keeping the page size server-defined. Fixes https://linear.app/chatwoot/issue/CW-7172/allow-an-option-to-fetch-more-audit-logs |
||
|
|
68e358d732 |
feat: voice-call UX fixes (#14579)
## Linear ticket https://linear.app/chatwoot/issue/CW-7187/voice-calls-followup-tasks ## Description Improvements to the WhatsApp voice-calling experience plus a cheaper, more accurate audio-transcription model. - First-time callers now get a real name. An inbound WhatsApp call creates the contact from the caller's WhatsApp profile name instead of the bare phone number. - Clear, consistent call attribution. Call bubbles show a unified "Handled by {agent}" - Cleaner call widget. The dismiss (✕) button is shown only for incoming calls - WhatsApp calling for manual inboxes. voice_calling_supported? now covers any whatsapp_cloud inbox - Transcription: whisper-1 → gpt-4o-mini-transcribe. ## 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 |
||
|
|
d20950c5b4 |
feat: scheduler fairness [AI-159] (#14425)
# Pull Request Template ## Description Better scheduling and queueing mechanics for document auto-sync - add jitter plan wise for document sync - move auto-sync documents to purgeable queue ## Type of change Please delete options that are not relevant. - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. locally tested and with specs ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com> |
||
|
|
94daf26ead |
chore: update jwt and faraday (#14577)
This PR updates two dependencies — `faraday` (2.14.1 → 2.14.2) and `jwt` (2.10.1 → 2.10.3) — to pick up security patches flagged by `bundle-audit`. Both are bumped to the minimal patched release within their existing major lines to keep the blast radius small. ### Faraday `Faraday::Connection#build_exclusive_url` still allowed a protocol-relative host override when the request target was passed as a `URI` object (rather than a `String`), bypassing the earlier fix for the string-based variant (CVE-2026-25765 / GHSA-33mh-2634-fwr2). On a fixed-base connection this could redirect a request to an attacker-controlled host while still forwarding connection-scoped headers such as `Authorization` — i.e. off-host request forgery (CVE-2026-33637 / GHSA-5rv5-xj5j-3484). The fix is a clean patch bump to `2.14.2`, within Faraday's existing version range — no API changes and no other gems affected. ### JWT `jwt` 2.10.1 accepts an empty/`nil` HMAC key during verification: `JWT.decode(token, "", true, algorithm: 'HS256')` (and keyfinder paths returning `""`/`nil`) verify a forged token, because the empty-key HMAC digest is treated as valid and `enforce_hmac_key_length` defaults to `false` (CVE-2026-45363, High). The advisory offers two fixes — `~> 2.10.3` or `>= 3.2.0`. We chose **2.10.3** deliberately: jumping to 3.x cascaded into upgrading `oauth2`, `twilio-ruby`, `googleauth`, `web-push`, and `signet` (all pinned `jwt < 3.0`), and `jwt` is used directly in 8+ places here (token services, OAuth callbacks, integration helpers), so a major bump carries real breakage risk for no extra security benefit. The Gemfile is pinned `'~> 2.10', '>= 2.10.3'` to hold the 2.x line. **Spec changes.** 2.10.3 tightens key handling: HMAC sign/verify now raises on a `nil`, empty, or non-`String` key instead of silently coercing it. A few specs relied on the old lax behaviour and needed updating: - `microsoft` / `google` callback specs built unsigned ID tokens via `JWT.encode(payload, false)`. Replaced with the correct unsigned form, `JWT.encode(payload, nil, 'none')`. - `instagram` / `linear` / `shopify` helper specs have a "client secret not configured" context where `client_secret` is `nil`. Their shared `valid_token` `let` signed with that `nil` secret, which Ruby evaluates before the helper runs — now raising. Since the helper short-circuits on the blank secret and never decodes the token, those contexts now override `valid_token` with a throwaway string. **Production is unaffected.** Every production HMAC path uses a real, non-empty key — `Rails.application.secret_key_base` (`BaseTokenService`, `Widget::TokenService`) or a client secret guarded by `return if client_secret.blank?` (Instagram/TikTok/Shopify/Linear helpers). The one `nil`-key call, `JWT.decode(id_token, nil, false)` in `OauthCallbackController`, runs with verification disabled, so the key is never inspected. Twilio voice tokens use `Twilio::JWT::AccessToken` from `twilio-ruby`, not this gem. The specs failed precisely because they exercised the unsafe empty-key pattern the patch now blocks — production never did. |
||
|
|
7c16071fc7 |
fix: Support allowlisted private API inbox webhooks (#14548)
Self-hosted installations can now opt SafeFetch into private-network access after SSRF hardening. The default remains unchanged: private IP destinations are blocked unless the instance owner explicitly enables private-network requests with `SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true`. Fixes https://linear.app/chatwoot/issue/CW-7131 Fixes https://github.com/chatwoot/chatwoot/issues/14489 Fixes https://github.com/chatwoot/chatwoot/issues/14494 ## How to use For self-hosted installations that need API inbox webhooks, or other SafeFetch-backed requests, to call trusted private services, enable private-network access with a single environment variable: ```bash SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true ``` This is disabled by default. Enable it only when the instance owner controls the deployment network and trusts the configured URLs. |
||
|
|
b981ba766f |
feat: support bulk label removal (#14534)
Adds bulk label removal alongside the existing assign-label action for conversations and contacts, so teams can clean up labels across selected records without opening each item individually. For conversations, the remove dropdown is scoped to labels that are actually applied across the current selection — so agents no longer see (or accidentally "remove") labels that aren't on any of the selected items. For contacts, the dropdown still lists all account labels for now; label data isn't carried on the contact list payload today, so scoping the contact remove menu cleanly is being tracked as a follow-up. ## Closes N/A ## How to test - Open the conversation list, select multiple conversations, open **Remove labels**, and confirm the dropdown only lists labels that are applied to at least one selected conversation. Pick a label and confirm it's removed from the selection. - Open Contacts, select multiple contacts, use **Remove Labels**, choose a label, and confirm the selected contacts are refreshed without that label. - Verify **Assign Labels** still works for conversations and contacts, and continues to show every available label. ## What changed - Adds an `action` prop to the shared `BulkLabelActions` dropdown so it can render in `assign` or `remove` mode. - Wires conversation bulk remove to the existing `labels.remove` backend path and filters the dropdown to the union of labels applied across the selected conversations. - Adds contact bulk remove support through `Contacts::BulkRemoveLabelsService`, routed by `Contacts::BulkActionService`. - Raises contact label save failures instead of reporting a successful bulk action when a contact update is invalid. ## Follow-ups - Scope the contact remove dropdown to applied labels (needs a lightweight endpoint, or eventually `cached_label_list` on `Contact`). ## Verification Conversation bulk remove selector: <img width="1680" height="1050" alt="Conversation bulk remove label selector" src="https://github.com/user-attachments/assets/2dba4a06-c497-45e1-85b0-e700164b6b2f" /> Contact bulk remove selector: <img width="1680" height="1050" alt="Contact bulk remove label selector" src="https://github.com/user-attachments/assets/b3b89959-5978-4064-b5f9-82b1a3e571dc" /> Video proof: https://github.com/user-attachments/assets/fffafe19-4e1c-4e2a-a135-c7182c06bb4d --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> |
||
|
|
37c8e7e699 |
fix: firecrawl long external link (#14566)
# Pull Request Template ## Description Fixes urls going past 255 chars, this is because of arabic urls, where each character balloons to 8-9 characters and goes past the 255 limit ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. specs ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules |
||
|
|
56e30102eb |
fix(whatsapp): store and surface unavailable coexistence messages (CW-7166) (#14547)
In WhatsApp coexistence setups (Business App + Cloud API on the same
number), some inbound customer messages arrive from Meta as `type:
unsupported` with error `131060` ("This message is unavailable") and no
content — typically the first message of a Click-to-WhatsApp /
Instagram-ad conversation, or a message synced from a companion device.
Chatwoot was dropping these webhooks entirely, so no contact,
conversation, or message was created. The conversation only surfaced
once an agent replied (via an `smb_message_echoes` event), starting
"headless" with zero customer context.
This change persists a placeholder message for these events so the
contact and conversation are created, and renders it with the dedicated
unsupported-message bubble that points agents to the WhatsApp app —
where the original message is still visible.
Fixes
https://linear.app/chatwoot/issue/CW-7166/whatsapp-coexistence-inbound-messages-are-silently-dropped
and https://github.com/chatwoot/chatwoot/issues/13464
<img width="3448" height="1604" alt="CleanShot 2026-05-22 at 17 49
35@2x"
src="https://github.com/user-attachments/assets/0a90ec84-9085-4cba-883d-08d9de33fa3c"
/>
## How to reproduce
1. Connect a WhatsApp Cloud (coexistence) inbox.
2. Receive an inbound message that Meta delivers as `type: unsupported`
with error `131060` (e.g. a Click-to-WhatsApp ad message, or a message
handled on a companion/primary device that fails to sync to the API).
3. **Before:** nothing is created — the conversation only appears after
an agent replies, with no record of the customer's first message.
4. **After:** the contact and conversation are created with an incoming
placeholder message rendered as the amber "unsupported" bubble: _"This
message is unsupported. You can view this message on the WhatsApp app."
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
|
||
|
|
6fbff026eb |
fix: skip AutoAssignment bulk loop when no agents are online (#14500)
## Description When an inbox has `enable_auto_assignment` and `assignment_v2` enabled but no agents are currently online, `AutoAssignment::AssignmentService#perform_bulk_assignment` still loaded up to 100 unassigned conversations and iterated each one, calling `inbox.available_agents` per conversation. Each call hits Redis presence lookups that return empty, no conversations get assigned, and the loop finishes having done only wasted work. For a busy inbox with a long unassigned backlog and offline agents, this is hundreds of Redis ops per job, multiplied by every `AutoAssignment::AssignmentJob` enqueue from the per-save handler. The pressure is significant when inbound volume is high. This adds a single early-return guard: if `inbox.available_agents.empty?`, return `0` immediately. Existing semantics are preserved (jobs are still enqueued on conversation events; they just exit cheaply when there is no one to assign to). ## Type of change - [x] Performance improvement (non-breaking change) ## Test coverage - [x] Added specs |
||
|
|
52da165cb7 |
feat: add timeout for imap email job and skip problematic emails (#11981)
# Pull Request Template ## Description Large emails (2MB+ with multiple attachments) were causing IMAP email processing jobs to timeout silently, blocking all subsequent emails from being processed. This created an infinite loop where: - Problematic emails were repeatedly fetched but never successfully processed - Other emails in the queue were never processed as we iterated sequentially - silent failures ### Solution Enhanced the FetchImapEmailsJob with individual email processing isolation: ### Key Changes 1. Individual Email Processing: Changed from map to each for better memory efficiency 2. Timeout Protection: Added configurable timeout per email (default: 60 seconds) 3. Failure Tracking: Track failed emails with 6-hour expiry for retry opportunities 4. Skip Logic: Skip emails that have failed 3+ times to prevent infinite loops 5. Error Isolation: Each email is processed in its own error boundary ### Configuration - Timeout: Configurable via EMAIL_PROCESSING_TIMEOUT_SECONDS using GlobalConfigService - Default: 60 seconds per email - Failure Limit: 3 attempts before skipping - Retry Window: 6 hours so that emails get 8 more chances in the 2 day window ### Benefits - Prevents queue blocking: One problematic email cannot stop others - Maintains email order: Older emails (customers waiting longer) processed first - Automatic recovery: Failed emails get retry opportunities - Better monitoring: Clear logging when emails timeout or are skipped - Configurable: Deployments can adjust the timeout based on their needs This fix ensures email processing reliability while maintaining existing functionality. ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? 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 |
||
|
|
3d20a7b049 |
feat: generate Help Center for Onboarding (#14370)
## Manually triggering help center generation
Open a Rails console (`bundle exec rails console`):
```ruby
account = Account.find(<ACCOUNT_ID>)
user = account.users.first
# Optional: refresh brand info from the customer's website
domain = 'example.com'
result = WebsiteBrandingService.new("noreply@#{domain}").perform
account.update!(
name: result[:title].presence || account.name,
custom_attributes: account.custom_attributes.merge('website' => domain, 'brand_info' => result)
)
# Optional: wipe existing portals so a fresh one is created
account.portals.destroy_all
Onboarding::HelpCenterCreationService.new(account, user).perform
```
Sidekiq must be running — articles are written by
`Onboarding::HelpCenterArticleGenerationJob`. Avoid running on
production; generation calls the LLM provider.
### Generation flow (Happy Path)
```mermaid
sequenceDiagram
autonumber
participant Kickoff as HelpCenterCreationService
participant DB as DB
participant GenJob as HelpCenterArticleGenerationJob
participant Curator as HelpCenterCurator
participant Firecrawl as Firecrawl
participant CuratorLLM as Curation LLM
participant Redis as Redis Progress
participant WriterJob as HelpCenterArticleWriterJob
participant Builder as HelpCenterArticleBuilder
participant WriterLLM as Writer LLM
participant Cable as ActionCable
Kickoff->>DB: Create portal for account<br/>homepage_link=https://chatwoot.com
Kickoff->>DB: Attach brand logo if available
Kickoff->>GenJob: Enqueue generation job<br/>account_id, portal_id, user_id, generation_id
GenJob->>Curator: Curate help center plan
Curator->>Firecrawl: map https://chatwoot.com<br/>search: docs help support faq
Firecrawl-->>Curator: Return discovered links
Curator->>CuratorLLM: Select categories + article plans<br/>from discovered links only
CuratorLLM-->>Curator: Return categories, articles, allowed_urls
GenJob->>DB: Create portal categories
GenJob->>GenJob: Stamp articles with category_id
GenJob->>GenJob: Filter article URLs against allowed_urls
GenJob->>GenJob: Drop articles with no category<br/>or no approved source URLs
GenJob->>Redis: Start progress<br/>status=generating, total=N, finished=0
loop For each approved article
GenJob->>WriterJob: Enqueue writer job<br/>title, category_id, approved URLs
end
par Writer jobs run independently
WriterJob->>Builder: Build article from approved URLs
Builder->>Firecrawl: batch_scrape approved URLs
Firecrawl-->>Builder: Return Markdown source pages
Builder->>WriterLLM: Rewrite sources into one article
WriterLLM-->>Builder: Return title, description, Markdown content
Builder->>DB: Create draft portal article<br/>meta.source_urls
WriterJob->>Redis: Increment finished count
WriterJob->>Cable: Broadcast help_center.article_generated
end
WriterJob->>Redis: If finished >= total<br/>mark status=completed
WriterJob->>Cable: Broadcast help_center.generation_completed
```
### Redis State Management
```mermaid
stateDiagram-v2
[*] --> active_pointer_set
active_pointer_set --> generating: generation job creates valid plan
active_pointer_set --> skipped: curation skipped/failed
generating --> generating: each writer job increments finished
generating --> completed: finished == total
generating --> ignored_completion: generation_id superseded
skipped --> [*]
completed --> [*]
ignored_completion --> [*]
```
|
||
|
|
3cd8cf43ce |
fix: atomically claim conversation to prevent duplicate assignment (#14495)
## Description Fixes a bug under Assignment V2 where a single conversation could be reassigned dozens of times in a row by the system, producing long stacks of "Assigned to X by Automation System via <policy>" activity messages alternating between agents. After this change each unassigned conversation is assigned exactly once, even on busy inboxes. ## Fixes # (issue) ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ## How to reproduce 1. Enable `assignment_v2` on an account with at least 2 online agents in an inbox. 2. Generate sustained resolve/snooze activity in the inbox (each one enqueues `AutoAssignment::AssignmentJob` for the whole inbox). 3. Watch any one unassigned conversation while the jobs drain — pre-fix it picks up multiple back-to-back "Assigned to …" activity rows alternating between agents. ## 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 |
||
|
|
f33e469e9a |
feat: Unread Count: Frontend changes for showing unread count badges (3/3)[CW-6851] (#14372)
# Pull Request Template ## Description This is the third and final PR in a series of PRs for Introducing unread counts in the sidebar for inboxes and labels. In this PR: * Added frontend changes to show the badges for unread counts for Inboxes and Labels * Added specs for the changes Issue: https://linear.app/chatwoot/issue/CW-6851/support-unread-conversation-counts ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? Tested this locally. Cases to test: * Send a message from the widget and see if the count changes * Mark a conversation as unread and see the count change for inbox * Open an unread conversation as agent and see the count go down * Add a label to an unread conversation from sidebar right click action without opening the conversation and see the count of un-reads on the label change Added the screenshot of how it will look like <img width="614" height="990" alt="Screenshot 2026-05-05 at 7 00 11 PM" src="https://github.com/user-attachments/assets/99fbaa9f-bcf2-4d8d-86e2-5727f652a9dd" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> |
||
|
|
27f2c2b392 |
feat: Unread Count: added api, store refresher, invalidation and events (2/3)[CW-6851] (#14369)
# Pull Request Template ## Description This is the second PR in a series of PRs for Introducing unread counts in the sidebar for inboxes and labels. In this PR: * added api for unread counts * Added the store refresher and invalidation with event listeners * Added action cable event * Added specs for the changes Issue: https://linear.app/chatwoot/issue/CW-6851/support-unread-conversation-counts ## 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. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> |
||
|
|
40deaef458 |
feat: Store WhatsApp BSUID identifiers from inbound webhooks (#14436)
Adds storage support for WhatsApp business-scoped user identifiers received from Meta Cloud API and Twilio WhatsApp webhooks. The change keeps existing phone-based behavior intact, stores BSUID and parent BSUID values as additional `contact_inboxes.source_id` rows for the same contact, and allows BSUID-only inbound messages to create contacts, conversations, and messages without requiring a phone number. Related: https://github.com/chatwoot/chatwoot/issues/13837 **What changed** - Extended WhatsApp source ID validation to accept regular BSUID and parent BSUID formats. - For Meta Cloud API, stores phone, `user_id`, and `parent_user_id` identifiers as contact inbox source IDs when they are present. - For Twilio WhatsApp, stores phone, `ExternalUserId`, and `ParentExternalUserId` identifiers as contact inbox source IDs while preserving the existing `whatsapp:` Twilio source ID shape. - Supports BSUID-only inbound messages by creating a contact, contact inbox, conversation, and message even when the phone number is missing. - Links phone-first and later BSUID-only messages to the same contact when the first payload contains both phone and BSUID. - Stores WhatsApp usernames in contact `additional_attributes`, matching existing social channel patterns. - Keeps existing phone-based outbound and new-conversation behavior unchanged for this milestone. **How to test** 1. Send a Meta Cloud webhook payload with both `wa_id` and `user_id`. 2. Verify Chatwoot creates or finds the phone `contact_inbox` and also creates a BSUID `contact_inbox` for the same contact. 3. Send a later Meta Cloud payload for the same user with only `user_id` / `from_user_id`. 4. Verify Chatwoot finds the BSUID `contact_inbox` and creates the inbound message without requiring a phone number. 5. Send a Twilio WhatsApp webhook with `From: whatsapp:+E164`, `ExternalUserId`, and optionally `ParentExternalUserId`. 6. Verify Chatwoot stores the Twilio phone and BSUID identifiers as `whatsapp:`-prefixed source IDs for the same contact. 7. Send a Twilio WhatsApp webhook where `From` is `whatsapp:<BSUID>` and there is no phone number. 8. Verify Chatwoot creates the contact, contact inbox, conversation, and message without a phone number. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |
||
|
|
3fae800936 |
feat: base layer for unread counts (store, counter and builder) (1/3)[CW-6851] (#14368)
## Description This is the first PR in a series of PRs for Introducing unread counts in the sidebar for inboxes and labels. In this PR: * Added the unread store, counter and builder modules * Added redis keys for unread count management * Added specs for all 3 modules, some specs are for testing enterprise only feature like specific roles and permissions which are added in the respective enterprise folder itself. **Note** None of this changes affect anything else and nothing is wired to existing modules. Issue: https://linear.app/chatwoot/issue/CW-6851/support-unread-conversation-counts ## 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. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> |
||
|
|
bca95efb82 | feat: add image resize support in articles (#14293) | ||
|
|
6560dbb68d |
feat: Add an option on the dashboard to allow switching help center layout (#14491)
<img width="633" height="431" alt="Screenshot 2026-05-18 at 12 32 55 PM" src="https://github.com/user-attachments/assets/682d4c5f-4c76-465b-8d2f-92fbc2bb2a40" /> --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> |
||
|
|
64585faff0 |
feat: Add a documentation layout design for public help center portal (#14403)
https://github.com/user-attachments/assets/fc4d15f9-2b54-4627-940f-94772ec739b1 --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> |
||
|
|
1d2f3e86dd |
feat(companies): track company last activity (#14435)
Tracks company recency from linked contact activity so the Companies list and detail page can show/sort by real customer engagement instead of generic record updates. ## Closes None. ## Why Company recency should reflect activity from people associated with the company. This keeps the signal tied to persisted contact activity, without treating passive online presence or widget heartbeat pings as company activity. ## What Changed - Adds a company helper to record `last_activity_at` from linked contact activity. - Rolls up `Contact#last_activity_at` changes to the associated company. - Initializes company activity when an already-active contact is associated with a company, including the business-email auto-association path. - Throttles company activity rollups to once every 5 minutes per company to avoid unnecessary writes during active conversations. - Treats company activity as monotonic: unlinking, moving, or deleting contacts does not move a company's activity timestamp backwards. - Leaves historical backfill, online presence tracking, widget visit tracking, and richer activity attribution out of scope. ## How to Test 1. Open an account with Companies enabled and visit the Companies list. 2. Trigger activity for a contact that belongs to a company, for example by receiving or sending a message in that contact's conversation. 3. Confirm the linked company shows a recent activity timestamp in the Companies list/detail page after the contact activity updates. 4. Associate an already-active contact with a company and confirm the company receives that contact's existing activity timestamp. 5. Confirm repeated contact activity within a short window does not continuously rewrite the company timestamp. --------- Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> |
||
|
|
3253e863ed |
fix: validate OpenAI hook credentials (#14068)
# Pull Request Template ## Description - Validates openai key while configuring hooks - added backfill logic Fixes # (issue) ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. locally <img width="1710" height="1234" alt="CleanShot 2026-04-15 at 16 15 02@2x" src="https://github.com/user-attachments/assets/3d319fe0-19f9-4fd0-9308-74987daac2e1" /> <img width="2884" height="1136" alt="CleanShot 2026-05-11 at 19 22 53@2x" src="https://github.com/user-attachments/assets/5eae8650-985b-4c4a-af42-35f7175ff52d" /> ## 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: Vishnu Narayanan <iamwishnu@gmail.com> |
||
|
|
059d840272 |
feat: Refresh llm settings when superadmin configs change [AI-151] (#14388)
# Pull Request Template ## Description fixes: https://linear.app/chatwoot/issue/AI-151/captains-super-admin-config-dont-get-applied-into-rails-without ## 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 To test locally: go to super admin -> settings -> captain -> Change endpoint to something incorrect go to local app -> captain -> playground -> try chatting (should fail due to incorrect endpoint) now in super admin captain settings, set the correct endpoint then chat in playground. Now it should work. Current develop code doesn't reflect the changes in installation config for captain instantly, needs a server restart. ## 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 |
||
|
|
b8deb89613 |
fix: make SAML callback session independent (#14467)
This PR makes SAML login independent of Rails session cookies ## Problem The normal SAML login flow should be straightforward: - User opens Chatwoot. - Chatwoot creates `_chatwoot_session`. - User starts SSO. - Chatwoot redirects the browser to the SAML provider. - The provider authenticates the user. - The provider sends the browser back to Chatwoot's ACS URL. - Chatwoot reads the SAML response, finds or creates the user, and logs them in. The fragile step is the ACS callback. Most SSO flows return to the app through browser redirects where cookies usually pass through as expected. **ADFS commonly returns the SAML response with a cross-site POST**. With Chatwoot's session cookie using `SameSite=Lax`, browsers may not send `_chatwoot_session` on that POST. SAML validation itself does not need the old Rails session cookie. The problem was our callback handoff after validation. DeviseTokenAuth stores the verified OmniAuth payload in Rails session, then redirects to a second callback route. If the browser does not preserve that session, Chatwoot has already received a valid SAML response but can no longer finish login. ## Solution This PR removes the session-backed handoff for SAML only: - The SAML callback completes login in the same request where OmniAuth validates the SAML response. - Chatwoot reads the verified auth payload directly from `request.env['omniauth.auth']`. - Account context and RelayState come from callback params or OmniAuth env data, not Rails session. - Other OmniAuth providers continue using the existing DeviseTokenAuth flow. - Mobile SAML still works when the IdP returns `RelayState=mobile`; the callback redirects to the mobile deep link with the generated SSO token. The previous SAML override used `303 See Other` to avoid replaying the SAML POST into the second callback route. This change keeps that intent, but removes the second callback route for SAML entirely. ## Screen recording ### SP Initiated https://github.com/user-attachments/assets/b0735e93-3864-4cc3-b6fc-419fff4b549e ### IDP Initiated https://github.com/user-attachments/assets/3ded0246-933c-4c85-9b7c-fa15fdc34883 ## Testing Manual validation: - Complete a SAML login. - In the browser network trace, find the IdP POST to `/omniauth/saml/callback?account_id=<account-id>`. - Confirm it redirects directly to `/app/login?...sso_auth_token=...` for web login. - For mobile, confirm `RelayState=mobile` redirects to the configured mobile deep link. - Confirm there is no intermediate `/auth/saml/callback` request. Testing with mocksaml.com: - Configure Chatwoot with a public `FRONTEND_URL`. - Set the mocksaml ACS URL to: ```text https://<chatwoot-host>/omniauth/saml/callback?account_id=<account-id> ``` - Set the mocksaml audience/SP entity ID to the value shown in Chatwoot SAML settings, usually: ```text https://<chatwoot-host>/saml/sp/<account-id> ``` - Use an email returned by mocksaml that exists in the SAML-enabled account. - Start login from Chatwoot's SSO login page. - Confirm the callback redirects directly to the app login URL with an SSO token. --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> |
||
|
|
ef27e571f7 |
feat: enable quoted reply for everyone (#14469)
Quoted email replies is now available to every account by default.
Previously this was gated behind the `quoted_email_reply` account-level
feature flag, so accounts needed it toggled on (via Super Admin) before
agents saw the toggle in the reply box.
## How to test
1. Open any conversation on an email inbox.
2. Confirm the **Quote previous email** toggle is visible in the reply
box (and is **not** visible on private notes or non-email channels).
3. Toggle it on, type a reply, and send — the outbound email should
include the quoted prior email below your message.
4. Toggle it off and send another reply — the quoted block should not
appear.
5. The toggle preference should persist per channel type (UI setting),
as before.
6. Verify the toggle works on a brand new account with no feature flags
flipped on (previously it would have been hidden).
## What changed
- Removed all `isFeatureEnabledonAccount(..., QUOTED_EMAIL_REPLY)` gates
from `ReplyBox.vue`, so the toggle and quoted-content behavior are
unconditional on email channels.
- Removed the `QUOTED_EMAIL_REPLY` constant from
`dashboard/featureFlags.js`.
- Marked the flag as `deprecated: true` in `config/features.yml` (kept
the entry in place to preserve FlagShihTzu bit positions on existing
accounts; `deprecated: true` hides it from the Super Admin UI).
- Dropped the now-unnecessary
`account.enable_features('quoted_email_reply')` setup from the message
builder spec.
|
||
|
|
5f6bd951b9 |
fix: portals#create returns 500 when custom_domain is omitted (#14400)
## Description
`POST /api/v1/accounts/:account_id/portals` returns a generic 500
(`{"status":500,"error":"Internal Server Error"}`) whenever the request
body omits `custom_domain`. Root cause: `parsed_custom_domain` calls
`URI.parse(@portal.custom_domain)` and `URI.parse(nil)` raises
`URI::InvalidURIError`. Existing callers either had to know to pass
`"custom_domain": ""` as a workaround or hit a 500 with no useful
diagnostic.
This PR guards `parsed_custom_domain` against blank values so the
existing fall-through (`else @portal.custom_domain`) applies —
equivalent to passing an empty string.
It also moves the `process_attached_logo` guard from the helper into the
`create` call site so `create` mirrors `update` (`process_attached_logo
if params[:blob_id].present?`) and avoids an unnecessary signed-blob
lookup on every create that doesn't include a logo.
Fixes #14397
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Two new request specs in
`spec/controllers/api/v1/accounts/portals_controller_spec.rb` covering
the regression:
- `creates portal when custom_domain is omitted from request body` — the
previously-broken case, now returns 200.
- `creates portal when custom_domain is blank` — verifies the existing
workaround (`"custom_domain": ""`) still works after the change.
Manually verified against `chatwoot/chatwoot:latest` Docker image before
the fix (500) and against this branch (200) using the curl repro from
the issue.
```bash
curl -X POST "https://<host>/api/v1/accounts/<account_id>/portals" \
-H "Content-Type: application/json" \
-H "api_access_token: <token>" \
-d '{"name":"Test Portal","slug":"test-portal","color":"#3b82f6"}'
```
Before: `{"status":500,"error":"Internal Server Error"}`
After: `200 OK` with the portal payload.
## 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 (no doc
change needed — controller behaviour, fully backward-compatible)
- [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: Sojan Jose <sojan@pepalo.com>
|
||
|
|
dc332dd93e |
feat: add attachments endpoint for contact media view (#14391)
# Pull Request Template ## Description This PR adds an endpoint to fetch all attachments shared with or by a contact across all of their conversations. Results are scoped based on the access: * Admins can access all attachments * Agents can access attachments only from inboxes they belong to * Custom role agents are further filtered based on their conversation permissions Each attachment payload includes `conversation_id`, allowing the UI to deep-link back to the source conversation. Added `GET /api/v1/accounts/:account_id/contacts/:contact_id/attachments` under the existing contacts scope. Fixes https://linear.app/chatwoot/issue/CW-7021/add-media-view-to-the-contact-details-page ## Type of change - [x] New feature (non-breaking change which adds functionality) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> |
||
|
|
13f66e3a88 |
fix: incorrect scope across controllers (#14459)
Co-authored-by: Sojan Jose <sojan@pepalo.com> |
||
|
|
fbcb89e955 |
fix(swagger): prevent path traversal in docs controller (#14458)
This hardens the development/test Swagger docs endpoint by ensuring requested files are resolved only within the `swagger/` directory. This did not affect production security because the Swagger controller only renders files in development or test environments; production already returns `404`. The change still closes the scanner finding and prevents future automated reports from flagging the development-only path. ## Closes Addresses: GHSA-xhp7-ggjq-p2rg ## How to reproduce 1. Start Chatwoot locally in development. 2. Visit `/swagger/%2Fetc%2Fpasswd`. 3. Before this change, the endpoint could render files outside the Swagger directory in development/test. ## What changed - Resolve Swagger file requests relative to `Rails.root/swagger`. - Return `404` when the resolved path is outside the Swagger directory or does not point to a file. - Strip leading slashes from derived request paths. - Add a request spec for the encoded absolute-path case. ## How to test 1. Start the app locally. 2. Visit `/swagger` and confirm the ReDoc page loads. 3. Visit `/swagger/swagger.json` and confirm the Swagger JSON loads. 4. Visit `/swagger/%2Fetc%2Fpasswd` and confirm it returns `404` with no file contents. Note: `bundle exec rspec spec/controllers/swagger_controller_spec.rb` was passing locally earlier during this fix. A final rerun before opening the PR was blocked because local Postgres on `localhost:5432` was not accepting connections. Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> |
||
|
|
8712879681 |
test: Stabilize SafeFetch spec against constant-identity flake (#14454)
`spec/lib/safe_fetch_spec.rb` has been flaking intermittently under full-suite runs with errors like: ``` expected SafeFetch::FileTooLargeError, got #<SafeFetch::FileTooLargeError: exceeded 1048576 bytes> ``` The class name on both sides is identical — yet RSpec reports a mismatch. This PR replaces the constant-identity assertions in this spec with a name-based matcher so the comparison stops depending on the live Class object's identity. We have made a similar fix earlier, but that wasn't addressing the core of the issue in #14139. **How to reproduce:** The flake only surfaces under load. Running the spec in isolation almost always passes, here's a [run failing in CI](https://github.com/chatwoot/chatwoot/actions/runs/25852294516/job/75961520248?pr=14370) ## What's actually going on Three facts combine: 1. **Test env reloads classes.** `config/environments/test.rb` sets `cache_classes = false` so Zeitwerk reloads autoloadable code on demand. 2. **`lib/` is on the reloadable autoload tree.** `config/application.rb` adds `lib` to `eager_load_paths`, which (with `eager_load = false` in test) makes it lazily loaded by Zeitwerk's *main* (reloading) autoloader. `lib/safe_fetch/` lives under that umbrella. 3. **RSpec's `raise_error(Klass)` snapshots the Class object.** `raise_error` matcher captures `Klass` (a specific `Class` instance) when the matcher is built. At raise time it compares with `Module#===`, which is identity-based. When the executor between examples triggers `Rails.application.reloader.reload!`, Zeitwerk does `remove_const(:SafeFetch)` and re-installs an autoload trigger. The next access produces a **fresh** `Class` object for `SafeFetch::FileTooLargeError` — same name, different identity. The matcher's snapshot now points at the dead Class, and the live raise produces the new one. Identity fails, even though the error is semantically correct. This bites SafeFetch specifically because: - SafeFetch is a *namespace* with 7 nested error classes — one reload invalidates all of them. - The spec contains 14+ `raise_error(SafeFetch::Foo)` assertions — many chances to land in a reload window. - SafeFetch is exercised by request-driven code (webhook delivery, avatar fetch). Earlier specs in the suite warm up the reloader machinery, which then fires during this spec. Other custom-error specs don't visibly flake because their consumers `rescue ConstName => e` (dynamic class lookup at raise time, walks the ancestor chain via `Module#===` *at the moment of raise*, with no captured snapshot), rather than RSpec's snapshot-then-compare pattern. ## What this PR does Adds a small local helper to the spec that matches by class name string, not by Class identity: ```ruby def safe_fetch_error(name, message_pattern = nil) satisfy("raise SafeFetch::#{name}#{" matching #{message_pattern.inspect}" if message_pattern}") do |error| error.class.name == "SafeFetch::#{name}" && (message_pattern.nil? || message_pattern.match?(error.message)) end end ``` Every `raise_error(described_class::FooError)` becomes `raise_error(safe_fetch_error('FooError'))`. Class names are strings; string equality survives any number of reloads. The semantic assertion ("this raised the right kind of error") is preserved. This is the pattern CLAUDE.md already endorses for this codebase: > Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors. ## Why this approach (even though it's suboptimal) To be honest with reviewers: **this is a workaround, not a root-cause fix.** Future spec authors who use `raise_error(SafeFetch::Foo)` in other files will hit the same flake. The "real" fix removes the failure mode at the source rather than dodging it per-spec. Here's the menu of options we considered and the tradeoffs: ### Option A — Spec-side name matcher (this PR) - **What:** the helper above. - **Pro:** one-file change, zero blast radius beyond the spec. - **Pro:** matches CLAUDE.md's documented stance. - **Con:** every new spec touching reloadable error classes needs to remember this pattern. It's a discipline tax, not a structural fix. - **Verdict:** chosen for this PR. ### Option B — Pin SafeFetch outside Zeitwerk ```ruby # config/application.rb Rails.autoloaders.main.ignore( Rails.root.join('lib/safe_fetch.rb'), Rails.root.join('lib/safe_fetch'), ) require Rails.root.join('lib/safe_fetch') ``` - **Pro:** real root-cause fix. `SafeFetch::*` constants become process-lifetime stable. The spec helper becomes unnecessary, every assertion in any spec works correctly. - **Pro:** preserves the public API exactly. - **Con:** loses hot-reload for SafeFetch in dev (need server restart to see edits). - **Con:** modifies `application.rb`, which has cross-team review weight. - **Verdict:** rejected for scope reasons in this PR; a sensible follow-up. ### Option C — Move error classes to a non-reloadable location Define top-level error classes in `config/initializers/safe_fetch_errors.rb`. Initializers run once at boot, constants are never reloaded. - **Pro:** identity-stable errors, no spec helper needed. - **Con:** API change — `SafeFetch::FileTooLargeError` becomes `SafeFetchFileTooLargeError` (or similar). Every consumer's `rescue` clause has to update. - **Con:** namespace pollution at top-level. - **Verdict:** rejected. The constraint of touching initializers is what motivated the simpler PR. ### Option D — Vendor SafeFetch as a path gem Move `lib/safe_fetch/` → `vendor/gems/safe_fetch/` with a gemspec. Bundler `require`s gems once; Zeitwerk has zero involvement. - **Pro:** structurally the most correct fix. Same identity stability as Option B, no Zeitwerk plumbing required. - **Pro:** zero API change for consumers. - **Con:** larger refactor (6+ files moved, gemspec authored, Gemfile/Gemfile.lock updated). - **Con:** SafeFetch becomes harder to iterate on in dev (gem-style edit-then-restart loop). - **Verdict:** rejected for scope in this PR; the cleanest long-term home for a security primitive. ### Option E — Drop custom errors, return a `Result` Refactor SafeFetch to yield a result hash (`{ ok: true, ... }` / `{ ok: false, kind: :unsafe_url, ... }`) instead of raising for anticipated outcomes. Failure kinds become symbols, which are interned for the process lifetime. - **Pro:** eliminates the failure mode at its true root — there are no custom exception classes to reload, anywhere. - **Pro:** forces explicit, exhaustive handling at every call site — a feature for a security primitive. - **Pro:** spec assertions become data assertions (`expect(result).to match(ok: false, kind: :too_large)`), which are robust against any reload, any reordering. - **Con:** paradigm shift away from the exception-driven style used everywhere else in the codebase. - **Con:** every consumer rewrites their `rescue` block into pattern-matched handling. - **Verdict:** rejected for scope in this PR; the architecturally cleanest answer if we ever revisit SafeFetch's API. ## Why we're shipping A despite knowing B–E are better This flake has been chewing CI time intermittently and previously took a partial fix (#14139). We need it stable *now*. Options B–E are real refactors with broader review surface (`application.rb`, consumer code, or the lib's public contract). Option A: - Costs nothing — one helper, mechanical replacements. - Doesn't preclude any of B/C/D/E later. The helper goes away cleanly once a root-cause fix lands. - Aligns with the project's documented guidance for this scenario. When SafeFetch next gets a substantive change, that's the right moment to fold in B or D. Until then, the spec is stable and CI gets its time back. ## What changed - `spec/lib/safe_fetch_spec.rb`: added `safe_fetch_error(name, message_pattern = nil)` helper; converted all 14 `raise_error(described_class::FooError[, /regex/])` assertions to `raise_error(safe_fetch_error('FooError'[, /regex/]))`. |
||
|
|
05bda5f742 |
feat: don't let onboarding write domain (#14442)
Stop the onboarding flow from writing the user's company website into `accounts.domain`. That column is reserved for the inbound email domain used to construct reply-to addresses (`reply+<uuid>@<domain>`), and silently overloading it from onboarding was breaking email continuity for accounts whose domain MX didn't point at Chatwoot's inbound — customer replies were going to an unreachable address. The website value now lives in `custom_attributes.website`, which is what the rest of the app already treats as the "company website" field. |
||
|
|
379e28df1f |
fix: prevent bot metrics double-counting when handoff and resolution coexist [CW-6210] (#14032)
The bot metrics dashboard can show `handoff_rate + resolution_rate >
100%`. A single conversation can accumulate both
`conversation_bot_handoff` and `conversation_bot_resolved` events, and
the rate queries count them independently against a shared denominator.
## How it happens
```
Customer messages bot inbox
│
▼
┌──────────┐
│ pending │ (bot handling)
└────┬─────┘
│ bot can't help
▼
┌──────────┐
│ open │ (handed off → conversation_bot_handoff event created)
└────┬─────┘
│ agent clicks "Resolve" WITHOUT sending a message
▼
┌──────────┐
│ resolved │ conversation_resolved fires
└──────────┘
│
▼
create_bot_resolved_event guard checks:
✅ inbox.active_bot?
✅ no outgoing messages with sender_type: 'User' ← agent never messaged!
│
▼
conversation_bot_resolved event ALSO created ← BUG
│
▼
Same conversation counted in BOTH rates → sum exceeds 100%
```
## Why fix at the read path, not the write path
An earlier attempt added guards in the listener to make the two events
mutually exclusive per conversation — deleting `bot_resolved` when a
handoff fires, suppressing resolutions when a handoff exists. This was
rejected because conversations can be reopened across multiple cycles
(bot resolves on day 1, customer returns on day 5, bot hands off).
Deleting the day-1 resolution corrupts historical reports, and the async
event dispatcher makes listener-level guards vulnerable to race
conditions.
## What this PR does
Within a reporting window, if a conversation has both events, **handoff
wins** — the conversation is excluded from the resolution count. This is
applied via SQL subquery across all three read paths:
```
┌─────────────────────────┐
│ Reporting Events DB │
│ │
│ conv_bot_handoff: [A,B] │
│ conv_bot_resolved: [A,C]│
└────────┬────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
BotMetricsBuilder ReportHelper CountReportBuilder
(rate cards) (bot_summary) (timeseries charts)
│ │ │
▼ ▼ ▼
resolutions: resolutions: resolutions:
[A,C] minus [A,B] same logic same logic
= [C] only = [C] only = [C] only
Result: Conversation A → handoff only
Conversation B → handoff only
Conversation C → resolution only
```
For wide date ranges spanning multiple lifecycles, a conversation
bot-resolved in one cycle and handed off in a later cycle will only show
as a handoff. This is an acceptable tradeoff — the alternative (>100%
rates) is clearly worse, and narrow ranges handle this correctly since
the events fall into different windows. No reporting events are
modified, so historical data stays intact.
## Diagnostic tool
`rake bot_metrics:diagnose` — read-only task that prompts for account ID
and date range, shows a before/after rate comparison without modifying
data.
---------
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
|
||
|
|
71cc5168be |
feat(linear): Auto link Linear issues from private notes (#14405)
When an agent pastes a Linear issue URL into a private note on a
conversation, Chatwoot now links the issue to the conversation
automatically — no need to click "Link to Linear issue" first. The
standard activity message ("X linked Linear issue ABC-123") is posted
just like a manual link.
Fixes
[CW-7032](https://linear.app/chatwoot/issue/CW-7032/if-someone-post-a-linear-url-in-the-private-notes-automatically-link)
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
|
||
|
|
58fdd20625 |
test(voice): WhatsApp Cloud Calling specs [5] (#14357)
Backend test coverage for the WhatsApp Cloud Calling pipeline introduced in #14356. Stacked on top of that PR so the controller and service under test exist when CI runs. ## Closes - Replaces #14348 (which was based on the abandoned \`feature/pla-150\`) ## What's covered - \`spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb\` (new, ~210 lines) - \`show / accept / reject / terminate / initiate / upload_recording\` happy paths - 422 paths: missing sdp_offer, missing recording, calling_disabled inbox, missing contact phone, ringing-state guards, AlreadyAccepted, NotRinging, CallFailed - 138006 (no permission) → throttled opt-in template send under conversation lock; idempotency on retry - \`upload_recording\` idempotency guard (\`already_uploaded\`) - \`spec/enterprise/services/whatsapp/call_service_spec.rb\` (new, ~135 lines) - State machine: ringing → in_progress → completed; ringing → failed (reject); ringing → no_answer (terminate) - Lock contention: concurrent terminate during accept doesn't corrupt the message/conversation broadcast - Provider failure paths surface as \`Voice::CallErrors::CallFailed\` (transport and business) - \`spec/models/channel/whatsapp_spec.rb\` — extends existing file with \`voice_enabled?\` matrix (provider × source × calling_enabled) ## Verification - 77/77 examples pass locally on this branch (controller + service + channel + incoming-call + permission-reply + open-ai message builder) - RuboCop clean ## Stack - Backend: #14356 (\`feat/whatsapp-call-meta-bridge\` — base of this PR) - FE: #14346 (\`feat/whatsapp-call-ui\`) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6c67eb9ba0 |
fix(notifications): Respect conversation access when notifying agents (#14412)
Agents with limited custom roles were receiving notifications (creation, assignment, mentions, new messages, SLA) for conversations they couldn't actually open. For example, an agent whose custom role only grants `conversation_unassigned_manage` was getting notified about conversations assigned to other agents. Notifications now go through the same `ConversationPolicy#show?` check that gates the conversation view itself, so an agent only gets notified for conversations they're permitted to see. Administrators and agents without custom roles are unaffected. --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> |
||
|
|
de696a55cb |
feat(voice): add WhatsApp inbound call webhook pipeline [3] (#14315)
Adds the server-side flow that turns Meta WhatsApp Cloud Calling webhooks into Chatwoot Calls, conversations, voice_call message bubbles, and ActionCable broadcasts. Stacked on top of #14312 (PR-2 — provider methods); intentionally does not include the HTTP controller, routes, or frontend (those land in PR-4 and PR-9). ## Closes - Part of the WhatsApp Cloud Calling rollout. Linear: TBD ## What changed **Webhook routing** - `app/jobs/webhooks/whatsapp_events_job.rb` — append `prepend_mod_with('Webhooks::WhatsappEventsJob')` so EE can extend it without forking. - `enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb` (new) — overlay that prepends `handle_message_events` to intercept `field: 'calls'` payloads (route to `Whatsapp::IncomingCallService`) and `interactive.call_permission_reply` messages (route to `Whatsapp::CallPermissionReplyService`); falls through with `super` for regular messages. **Services** - `enterprise/app/services/whatsapp/incoming_call_service.rb` (new) — gated on `provider_config['calling_enabled']`; processes `connect` (creates inbound call via `Voice::InboundCallBuilder` or transitions an existing outbound call to `in_progress`) and `terminate` events; updates conversation `additional_attributes` and broadcasts `voice_call.incoming`/`voice_call.outbound_connected`/`voice_call.ended`. - `enterprise/app/services/whatsapp/call_permission_reply_service.rb` (new) — handles WhatsApp interactive `call_permission_reply` replies; clears the conversation's `call_permission_requested_at` flag and broadcasts `voice_call.permission_granted` so the agent UI can re-enable the call button. **Builder/model adjustments** - `enterprise/app/services/voice/inbound_call_builder.rb` — provider-agnostic; accepts `provider:` and `extra_meta:` kwargs, drops `account:` (now derived from `inbox.account` to keep the param count under rubocop's ceiling without disabling cops), uses digits-only `source_id` for WhatsApp ContactInbox (validation requires `^\d{1,15}\z`), skips Twilio-only `conference_sid` for non-Twilio providers. - `enterprise/app/services/voice/call_message_builder.rb` — adds `create!`/`update_status!` API and `CALL_TO_VOICE_STATUS` map; uses direct `Message.create!` (bypasses `Messages::MessageBuilder`'s incoming-on-non-Api-inbox guard, which would otherwise reject the system bubble); content is `'WhatsApp Call'` for WhatsApp and `'Voice Call'` for Twilio. Backwards-compatible `perform!` retained for the existing Twilio call sites. - `enterprise/app/models/call.rb` — adds `default_ice_servers` (driven by `VOICE_CALL_STUN_URLS` env), `direction_label` alias for the `inbound`/`outbound` strings the FE expects, and `ringing?`/`in_progress?`/`terminal?` predicates used throughout the pipeline. **Outgoing-channel guard** - `app/services/base/send_on_channel_service.rb` — extends `invalid_message?` to skip messages with `content_type == 'voice_call'`. Without this, agent-initiated outbound calls (PR-4) would deliver \"WhatsApp Call\" as a text message to the contact every time. **Twilio call-site update** - `enterprise/app/controllers/twilio/voice_controller.rb` — drops the now-redundant `account: current_account` kwarg from the `Voice::InboundCallBuilder.perform!` call. **Tests** - New: `spec/enterprise/services/whatsapp/incoming_call_service_spec.rb` (5 examples — calling-disabled, inbound connect, outbound connect, terminate completed, terminate no-answer, unknown event). - New: `spec/enterprise/services/whatsapp/call_permission_reply_service_spec.rb` (3 examples — accept, reject, calling-disabled). - Updated: `spec/enterprise/services/voice/inbound_call_builder_spec.rb` and `spec/enterprise/controllers/twilio/voice_controller_spec.rb` to drop the `account:` kwarg from call expectations. ## How to test In `rails console` against an account with a WhatsApp inbox where `provider_config['calling_enabled']` is true: ```ruby inbox = Inbox.find(<id>) params = { calls: [{ id: 'wacid_test', from: '15550001111', event: 'connect', session: { sdp: 'v=0...', sdp_type: 'offer' } }] } Whatsapp::IncomingCallService.new(inbox: inbox, params: params).perform # => Conversation + Call (status: 'ringing', provider: 'whatsapp') + voice_call message bubble # => ActionCable broadcasts `voice_call.incoming` to the assignee or account-wide # Then terminate it: Whatsapp::IncomingCallService.new(inbox: inbox, params: { calls: [{ id: 'wacid_test', event: 'terminate', duration: 0, terminate_reason: 'no_answer' }] } ).perform # => Call status flips to 'no_answer', message bubble updates, `voice_call.ended` broadcast fires ``` End-to-end browser flow (Meta → cable → UI) requires the controller from PR-4 and the frontend from PR-9. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3489298726 |
feat: add WidgetCreationService for onboarding web widget setup (#14314)
When a new account finishes onboarding we want to land them on a
dashboard with a working web widget already configured, branded, named,
and assigned to them, instead of an empty inbox list. This PR adds the
services that produce that widget. **No user-visible change yet:** the
services are dormant until the trigger and background job are wired up
in the follow-up PR.
## Context
Milestone 1 added `Account::BrandingEnrichmentJob`, which calls
context.dev during signup and stores brand data on
`account.custom_attributes['brand_info']`, plus the new onboarding form
that captures `domain`, `name`, `industry`, etc. Milestone 2 starts
using that data, and the first thing we want is a web widget
materialized automatically. Splitting the service layer from the
orchestration plumbing (Redis key, `onboarding_step` extension,
controller wiring, ActionCable) keeps this diff focused and lets the
LLM/widget logic merge independently.
## How to test
Run against an existing account that already has `brand_info` populated.
```ruby
account = Account.find(<account_id>)
user = account.administrators.first
inbox = WidgetCreationService.new(account, user).perform
inbox.channel.widget_color # color from brand_info, or '#1f93ff'
inbox.channel.welcome_title # brand_info[:title], or account.name
inbox.channel.welcome_tagline # LLM tagline (Enterprise + system key set),
# else brand_info[:slogan]/[:description]/nil
inbox.inbox_members.pluck(:user_id)
```
Toggle `InstallationConfig['CAPTAIN_OPEN_AI_API_KEY']` to flip between
LLM and brand-text tagline paths. To verify failure isolation, raise
inside `Captain::Llm::WidgetTaglineService#perform` and confirm widget
creation still succeeds with the fallback tagline.
|
||
|
|
bc768bf04f | chore: verbosely log errors for leadsquare activity failure (#14407) | ||
|
|
202403873d |
feat: Ability to specify the authentication type for imap server (#12306)
# Pull Request Template ## Description This PR adds IMAP authentication mechanism selection to Chatwoot's email inbox configuration. Users can now choose between 'plain', 'login', and 'cram-md5' authentication methods when configuring IMAP settings, providing flexibility for different email providers that require specific authentication types. https://github.com/chatwoot/chatwoot/issues/8867 The implementation includes: - Frontend dropdown with numeric keys (1, 2, 3) matching SMTP auth style - Backend API validation for allowed authentication mechanisms - Consistent 'cram-md5' format throughout the codebase - Updated IMAP service to handle different auth types properly This feature maintains consistency with existing SMTP authentication options and follows the established UI/UX patterns in the application. ## Type of change Please delete options that are not relevant. - [x] New feature (non-breaking change which adds functionality) - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? ### Manual Testing: - Tested in Docker environment - Verified IMAP auth dropdown appears in inbox settings - Confirmed all three auth mechanisms (plain, login, cram-md5) can be selected and saved - Tested API validation by attempting to save invalid auth mechanisms ### Automated Testing: - Updated existing IMAP service tests to use consistent lowercase values - Updated API controller tests for authentication parameter handling - All tests pass locally with the new changes ### Test Configuration: - Tested with both new and existing inbox configurations ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules ## Additional Notes - This feature is backward compatible and doesn't break existing IMAP configurations - The 'cram-md5' format is used consistently throughout (UI, API, storage, services) - Net::IMAP compatibility is maintained by converting to 'CRAM-MD5' internally - Follows the same pattern established by SMTP authentication configuration --------- Co-authored-by: João Santos <joao.santos@madigital.eu> Co-authored-by: Sony Mathew <sony@chatwoot.com> |
||
|
|
9c1d1c4070 |
feat(labels): remove label associations asynchronously on delete (#13531)
## Summary - Remove label deletion dependency on association cleanup by deleting immediately and enqueueing a background job. - Add `Labels::RemoveAssociationsJob` to strip deleted label references from tagged conversations and contacts. - Keep this version simple by removing the label count/prompt requirement requested. ## Implementation notes - Enqueue job from `Api::V1::Accounts::LabelsController#destroy` with label title + account id. - Background work performed in `Labels::DestroyService`. ## References - Linear issue: https://linear.app/chatwoot/issue/CW-4765/cw-2857-enhancement-removing-labels-is-inconsistent - GitHub issue: https://github.com/chatwoot/chatwoot/issues/1249 ## Testing - `bundle exec rspec spec/controllers/api/v1/accounts/labels_controller_spec.rb spec/services/labels/destroy_service_spec.rb spec/jobs/labels/remove_associations_job_spec.rb spec/services/labels/update_service_spec.rb` - `bundle exec rubocop app/controllers/api/v1/accounts/labels_controller.rb app/jobs/labels/remove_associations_job.rb spec/controllers/api/v1/accounts/labels_controller_spec.rb spec/jobs/labels/remove_associations_job_spec.rb spec/services/labels/destroy_service_spec.rb` --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> |
||
|
|
5c6ea78ce6 |
fix(security): Enforce admin authorization on custom attribute definitions API (#14392)
Custom attribute definitions can now only be created, edited, or deleted by administrators, matching the existing settings UI restriction. Previously, an agent could call the `custom_attribute_definitions` API directly and modify account configuration that they couldn't reach through the dashboard — a Broken Access Control vulnerability reported externally. Fixes https://linear.app/chatwoot/issue/CW-7038/broken-access-control-on-custom-attribute-definitions-api ## How to test 1. Sign in as an agent. 2. Try to create a custom attribute by calling `POST /api/v1/accounts/<id>/custom_attribute_definitions` directly (the settings page is hidden for agents — use curl with the agent's `api_access_token`). 3. Expect `401 Unauthorized` with body `{"error":"You are not authorized to do this action"}`. Repeat for `PATCH` and `DELETE`. 4. Sign in as an administrator and confirm create/edit/delete still work from Settings → Custom Attributes. 5. As either role, the listing endpoint (`GET .../custom_attribute_definitions`) should still succeed — agents need this to render attributes in conversation and contact panels. Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |