From 1de90ec13622802c8af9a2755aa5eec0643fe849 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:48:06 +0530 Subject: [PATCH 01/24] fix: clean up conversation list rendering (#14107) --- .../ConversationCard/ConversationCardExpanded.vue | 7 +++++-- app/javascript/dashboard/components/ConversationList.vue | 3 +-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue index 4386bba96..5a6324902 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue @@ -112,11 +112,14 @@ const selectedModel = computed({
-
+
-
+
Date: Mon, 20 Apr 2026 17:47:24 +0530 Subject: [PATCH 02/24] feat: Gate conversation continuity toggle behind inbound_emails feature flag (#13838) The "conversation continuity via email" toggle was visible to all accounts regardless of whether they had `inbound_emails` enabled. Without inbound email infrastructure, replies to those follow-up emails land in the agent's personal inbox instead of routing back into Chatwoot. The feature appears to work but silently breaks the reply path. The toggle is now gated on the `inbound_emails` feature flag. On self-hosted without the feature, the toggle is hidden entirely. On cloud, it remains visible but disabled with upgrade messaging. On the backend, `inbound_emails` is added to the manually managed features list in `InternalAttributesService` so that Stripe webhook plan syncs don't override it when support enables it for an account. --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../dashboard/i18n/locale/en/inboxMgmt.json | 1 + .../dashboard/settings/inbox/Settings.vue | 37 ++++++++++++++++--- .../accounts/internal_attributes_service.rb | 4 +- .../_form.html.erb | 5 +-- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 51f855689..6b1ff20b1 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -745,6 +745,7 @@ "SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name", "ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email", "ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.", + "ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.", "LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing", "LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts", "INBOX_UPDATE_TITLE": "Inbox Settings", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index c059c57f1..11eb1f6aa 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -112,9 +112,33 @@ export default { ...mapGetters({ accountId: 'getCurrentAccountId', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', + isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', uiFlags: 'inboxes/getUIFlags', portals: 'portals/allPortals', }), + isInboundEmailEnabled() { + return this.isFeatureEnabledonAccount( + this.accountId, + FEATURE_FLAGS.INBOUND_EMAILS + ); + }, + showContinuityToggle() { + if (this.isInboundEmailEnabled) return true; + return this.isOnChatwootCloud; + }, + isContinuityDisabled() { + return this.isOnChatwootCloud && !this.isInboundEmailEnabled; + }, + continuityDescription() { + if (this.isContinuityDisabled) { + return this.$t( + 'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT' + ); + } + return this.$t( + 'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT' + ); + }, selectedTabKey() { return this.tabs[this.selectedTabIndex]?.key; }, @@ -542,7 +566,8 @@ export default { welcome_tagline: this.channelWelcomeTagline || '', selectedFeatureFlags: this.selectedFeatureFlags, reply_time: this.replyTime || 'in_a_few_minutes', - continuity_via_email: this.continuityViaEmail, + continuity_via_email: + this.isInboundEmailEnabled && this.continuityViaEmail, }, }; if (this.avatarFile) { @@ -1148,15 +1173,15 @@ export default { /> diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb index 116d0a3fc..d119d6345 100644 --- a/enterprise/app/services/internal/accounts/internal_attributes_service.rb +++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb @@ -52,9 +52,9 @@ class Internal::Accounts::InternalAttributesService # Get list of valid features that can be manually managed def valid_feature_list - # Business and Enterprise plan features only Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES + - Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES + Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES + + %w[inbound_emails] end # Account notes functionality removed for now diff --git a/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb b/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb index 97b21090a..a4a18a9b9 100644 --- a/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb +++ b/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb @@ -2,9 +2,8 @@ # Get all feature names and their display names all_feature_display_names = SuperAdmin::AccountFeaturesHelper.feature_display_names - # Business and Enterprise plan features only - premium_features = Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES + - Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES + # Features that can be manually managed + premium_features = Internal::Accounts::InternalAttributesService.new(field.resource).valid_feature_list # Get only premium features with display names premium_features_with_display = premium_features.map do |feature| From 437dd9d38ce9c12ee5edbbde81e931e652feba30 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:16:41 +0530 Subject: [PATCH 03/24] fix: prevent Ctrl+Enter adding extra line break on send (Windows) (#14077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description This PR includes, On Windows, pressing **Ctrl+Enter** in the reply editor was inserting an unintended line break before sending. This led to two issues: * **Unexpected blank lines** After adding a line break with Shift+Enter and removing it with Backspace, the editor looked correct. However, sending with Ctrl+Enter reintroduced a hidden break, resulting in an extra blank line in the final message. * **Selected text being replaced** When text was selected and Ctrl+Enter was pressed, the selection was replaced with a line break instead of being sent. Fixes https://linear.app/chatwoot/issue/CW-6840/newline-bug-in-the-editor ### **Cause** Two keyboard handlers responded to **Ctrl+Enter** on Windows: * ProseMirror (`Mod-Enter`) inserted a hard break * ReplyBox (`$mod+Enter`) triggered send The existing guard only checked `metaKey` (Cmd), so it never worked on Windows. As a result, a line break was inserted just before sending. ### **Solution** Make the modifier check platform-aware so the editor correctly intercepts the send shortcut: * Added `detectOS`, `isMac`, and `OS` constants * Introduced `hasPressedMod` (uses `metaKey` on macOS, `ctrlKey` elsewhere) This ensures Ctrl+Enter sends the message without modifying content, while keeping existing behavior unchanged. **NB:** macOS behavior with Cmd+Enter remains unchanged ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Case 1: line break** 1. Type `hello` 2. Press Shift+Enter, then Backspace 3. Press Ctrl+Enter → Message contains an unexpected blank new line **Case 2: Selection replaced** 1. Type two lines using Shift+Enter 2. Select text on the second line 3. Press Ctrl+Enter → Selected text is replaced and not sent ### Screencast **Before** https://github.com/user-attachments/assets/d6d285a9-260b-4711-8bbd-d0c8519e8d20 **After** https://github.com/user-attachments/assets/c0ace1f7-5d22-44a2-8e08-22190ee21e61 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../shared/helpers/KeyboardHelpers.js | 17 +- app/javascript/shared/helpers/platform.js | 50 +++++ .../helpers/specs/KeyboardHelpers.spec.js | 126 ++++++++++++ .../shared/helpers/specs/platform.spec.js | 186 ++++++++++++++++++ 4 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 app/javascript/shared/helpers/platform.js create mode 100644 app/javascript/shared/helpers/specs/platform.spec.js diff --git a/app/javascript/shared/helpers/KeyboardHelpers.js b/app/javascript/shared/helpers/KeyboardHelpers.js index fdd64a083..d89cbba4e 100644 --- a/app/javascript/shared/helpers/KeyboardHelpers.js +++ b/app/javascript/shared/helpers/KeyboardHelpers.js @@ -1,3 +1,5 @@ +import { isApple } from './platform'; + export const isEnter = e => { return e.key === 'Enter'; }; @@ -14,13 +16,20 @@ export const hasPressedCommand = e => { return e.metaKey; }; +// True when the platform's "command" modifier is held: Cmd (metaKey) on +// Apple platforms (macOS, iOS/iPadOS hardware keyboards), Ctrl (ctrlKey) +// elsewhere. Mirrors the `$mod` convention used by tinykeys and +// prosemirror-keymap so the editor and the app agree on what counts as the +// send modifier. +export const hasPressedMod = e => Boolean(isApple() ? e.metaKey : e.ctrlKey); + export const hasPressedEnterAndNotCmdOrShift = e => { - return isEnter(e) && !hasPressedCommand(e) && !hasPressedShift(e); + return isEnter(e) && !hasPressedMod(e) && !hasPressedShift(e); }; -export const hasPressedCommandAndEnter = e => { - return hasPressedCommand(e) && isEnter(e); -}; +// Detects the platform-aware "send" shortcut: Cmd+Enter on Apple platforms, +// Ctrl+Enter on Windows/Linux. +export const hasPressedCommandAndEnter = e => hasPressedMod(e) && isEnter(e); // If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue // https://github.com/chatwoot/chatwoot/issues/9492 diff --git a/app/javascript/shared/helpers/platform.js b/app/javascript/shared/helpers/platform.js new file mode 100644 index 000000000..7ec897936 --- /dev/null +++ b/app/javascript/shared/helpers/platform.js @@ -0,0 +1,50 @@ +// Detects the current OS using the modern User-Agent Client Hints API, +// falling back to userAgent parsing on Safari/Firefox where it is unavailable. +// Treats iPad on iOS 13+ (which spoofs Macintosh) as iOS via maxTouchPoints. + +export const OS = Object.freeze({ + MAC: 'macos', + WINDOWS: 'windows', + LINUX: 'linux', + ANDROID: 'android', + IOS: 'ios', + UNKNOWN: 'unknown', +}); + +// navigator.userAgentData.platform → OS constant (lowercased keys) +const UAD_MAP = { + macos: OS.MAC, + windows: OS.WINDOWS, + linux: OS.LINUX, + android: OS.ANDROID, + ios: OS.IOS, +}; + +export function detectOS() { + if (typeof navigator === 'undefined') return OS.UNKNOWN; + + // Trust userAgentData only when it maps to a known OS; otherwise fall + // through to UA parsing so unmapped values (e.g. "Chrome OS") don't leak. + const uad = navigator.userAgentData?.platform?.toLowerCase(); + if (uad && UAD_MAP[uad]) return UAD_MAP[uad]; + + const ua = navigator.userAgent || ''; + if (/android/i.test(ua)) return OS.ANDROID; + if (/iPhone|iPod/.test(ua)) return OS.IOS; + if ( + /iPad/.test(ua) || + (/Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1) + ) { + return OS.IOS; + } + if (/Win/i.test(ua)) return OS.WINDOWS; + if (/Mac/i.test(ua)) return OS.MAC; + if (/Linux/i.test(ua)) return OS.LINUX; + + return OS.UNKNOWN; +} + +export const isApple = () => { + const os = detectOS(); + return os === OS.MAC || os === OS.IOS; +}; diff --git a/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js b/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js index 06b490b6c..8e996d0fb 100644 --- a/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js +++ b/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js @@ -3,9 +3,29 @@ import { isEscape, hasPressedShift, hasPressedCommand, + hasPressedMod, + hasPressedCommandAndEnter, + hasPressedEnterAndNotCmdOrShift, isActiveElementTypeable, } from '../KeyboardHelpers'; +const setNavigator = navigatorValue => { + Object.defineProperty(global, 'navigator', { + value: navigatorValue, + configurable: true, + writable: true, + }); +}; + +const onMac = () => setNavigator({ userAgentData: { platform: 'macOS' } }); +const onWindows = () => + setNavigator({ userAgentData: { platform: 'Windows' } }); +const onIOS = () => + setNavigator({ + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15', + }); + describe('#KeyboardHelpers', () => { describe('#isEnter', () => { it('return correct values', () => { @@ -30,6 +50,112 @@ describe('#KeyboardHelpers', () => { expect(hasPressedCommand({ metaKey: true })).toEqual(true); }); }); + + describe('#hasPressedMod', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + setNavigator(originalNavigator); + }); + + it('uses metaKey on macOS', () => { + onMac(); + expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true); + expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false); + }); + + it('uses ctrlKey on Windows', () => { + onWindows(); + expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(true); + expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(false); + }); + + it('uses metaKey on iOS hardware keyboards', () => { + onIOS(); + expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true); + expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false); + }); + + it('returns false when no modifier is held', () => { + onWindows(); + expect(hasPressedMod({ metaKey: false, ctrlKey: false })).toBe(false); + }); + }); + + describe('#hasPressedCommandAndEnter', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + setNavigator(originalNavigator); + }); + + it('returns true for Cmd+Enter on macOS', () => { + onMac(); + expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe( + true + ); + }); + + it('returns true for Ctrl+Enter on Windows (CW-6859 fix)', () => { + onWindows(); + expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe( + true + ); + }); + + it('returns false for Ctrl+Enter on macOS (Mac uses Cmd, not Ctrl)', () => { + onMac(); + expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe( + false + ); + }); + + it('returns true for Cmd+Enter on iOS hardware keyboards', () => { + onIOS(); + expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe( + true + ); + }); + + it('returns false for plain Enter', () => { + onWindows(); + expect(hasPressedCommandAndEnter({ key: 'Enter' })).toBe(false); + }); + }); + + describe('#hasPressedEnterAndNotCmdOrShift', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + setNavigator(originalNavigator); + }); + + it('returns true for plain Enter on Windows', () => { + onWindows(); + expect(hasPressedEnterAndNotCmdOrShift({ key: 'Enter' })).toBe(true); + }); + + it('returns false for Ctrl+Enter on Windows (mod is held)', () => { + onWindows(); + expect( + hasPressedEnterAndNotCmdOrShift({ key: 'Enter', ctrlKey: true }) + ).toBe(false); + }); + + it('returns false for Cmd+Enter on macOS (mod is held)', () => { + onMac(); + expect( + hasPressedEnterAndNotCmdOrShift({ key: 'Enter', metaKey: true }) + ).toBe(false); + }); + + it('returns false for Shift+Enter', () => { + onWindows(); + expect( + hasPressedEnterAndNotCmdOrShift({ key: 'Enter', shiftKey: true }) + ).toBe(false); + }); + }); }); describe('isActiveElementTypeable', () => { diff --git a/app/javascript/shared/helpers/specs/platform.spec.js b/app/javascript/shared/helpers/specs/platform.spec.js new file mode 100644 index 000000000..29bcc4339 --- /dev/null +++ b/app/javascript/shared/helpers/specs/platform.spec.js @@ -0,0 +1,186 @@ +import { detectOS, isApple, OS } from '../platform'; + +const setNavigator = ({ userAgentData, userAgent, maxTouchPoints } = {}) => { + Object.defineProperty(global, 'navigator', { + value: { userAgentData, userAgent, maxTouchPoints }, + configurable: true, + writable: true, + }); +}; + +describe('detectOS', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + Object.defineProperty(global, 'navigator', { + value: originalNavigator, + configurable: true, + writable: true, + }); + }); + + describe('with userAgentData available', () => { + it('returns OS.MAC for macOS', () => { + setNavigator({ userAgentData: { platform: 'macOS' } }); + expect(detectOS()).toBe(OS.MAC); + }); + + it('returns OS.WINDOWS for Windows', () => { + setNavigator({ userAgentData: { platform: 'Windows' } }); + expect(detectOS()).toBe(OS.WINDOWS); + }); + + it('returns OS.LINUX for Linux', () => { + setNavigator({ userAgentData: { platform: 'Linux' } }); + expect(detectOS()).toBe(OS.LINUX); + }); + + it('returns OS.ANDROID for Android', () => { + setNavigator({ userAgentData: { platform: 'Android' } }); + expect(detectOS()).toBe(OS.ANDROID); + }); + + it('falls through to userAgent for unmapped values like "Chrome OS"', () => { + setNavigator({ + userAgentData: { platform: 'Chrome OS' }, + userAgent: 'Mozilla/5.0 (X11; CrOS x86_64) AppleWebKit/537.36', + }); + // Not a mapped UAD value AND not a recognized UA pattern → unknown + expect(detectOS()).toBe(OS.UNKNOWN); + }); + + it('prefers userAgentData over userAgent when value is mapped', () => { + setNavigator({ + userAgentData: { platform: 'Windows' }, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + }); + expect(detectOS()).toBe(OS.WINDOWS); + }); + }); + + describe('with userAgent fallback', () => { + it('detects macOS from Safari userAgent', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', + }); + expect(detectOS()).toBe(OS.MAC); + }); + + it('detects Windows from userAgent', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }); + expect(detectOS()).toBe(OS.WINDOWS); + }); + + it('detects Linux from userAgent', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', + }); + expect(detectOS()).toBe(OS.LINUX); + }); + + it('detects Android from userAgent (before Linux match)', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36', + }); + expect(detectOS()).toBe(OS.ANDROID); + }); + + it('detects iOS from iPhone userAgent', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15', + }); + expect(detectOS()).toBe(OS.IOS); + }); + + it('detects iPadOS spoofing Macintosh via maxTouchPoints', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', + maxTouchPoints: 5, + }); + expect(detectOS()).toBe(OS.IOS); + }); + + it('returns OS.UNKNOWN when no match', () => { + setNavigator({ userAgent: 'SomeRandomBot/1.0' }); + expect(detectOS()).toBe(OS.UNKNOWN); + }); + + it('returns OS.UNKNOWN when userAgent is missing', () => { + setNavigator({}); + expect(detectOS()).toBe(OS.UNKNOWN); + }); + }); + + describe('without navigator', () => { + it('returns OS.UNKNOWN when navigator is undefined', () => { + Object.defineProperty(global, 'navigator', { + value: undefined, + configurable: true, + writable: true, + }); + expect(detectOS()).toBe(OS.UNKNOWN); + }); + }); +}); + +describe('isApple', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + Object.defineProperty(global, 'navigator', { + value: originalNavigator, + configurable: true, + writable: true, + }); + }); + + it('returns true on macOS', () => { + setNavigator({ userAgentData: { platform: 'macOS' } }); + expect(isApple()).toBe(true); + }); + + it('returns true on iOS (iPhone)', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15', + }); + expect(isApple()).toBe(true); + }); + + it('returns true on iPadOS spoofing Macintosh', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', + maxTouchPoints: 5, + }); + expect(isApple()).toBe(true); + }); + + it('returns false on Windows', () => { + setNavigator({ userAgentData: { platform: 'Windows' } }); + expect(isApple()).toBe(false); + }); + + it('returns false on Linux', () => { + setNavigator({ userAgentData: { platform: 'Linux' } }); + expect(isApple()).toBe(false); + }); + + it('returns false on Android', () => { + setNavigator({ userAgentData: { platform: 'Android' } }); + expect(isApple()).toBe(false); + }); +}); + +describe('OS constants', () => { + it('is frozen so callers cannot mutate it', () => { + expect(Object.isFrozen(OS)).toBe(true); + }); +}); From 7d42edd17f87d783e5e404f10b57dd35c7636657 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:00:20 +0700 Subject: [PATCH 04/24] fix: sanitize parentheses from email From header to prevent SMTP 553 errors (#14075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When an inbox name or business name contains parentheses — e.g. `Giro Crédito - Soporte (Email` — the resulting From header becomes unparseable by SMTP servers. The `(` is interpreted as an RFC 5322 comment start, swallowing the actual email address and causing a `553 Invalid email address` rejection. Closes [CW-6323](https://linear.app/chatwoot/issue/CW-6323) ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How to reproduce? 1. Set an inbox's name or business name to include a parenthesis, e.g. `Support (Email` 2. Send an outgoing email reply from that inbox 3. Observe `Net::SMTPFatalError: 553 ... Invalid email address` ## 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 --- app/builders/email/base_builder.rb | 2 +- app/mailers/conversation_reply_mailer.rb | 2 +- app/models/inbox.rb | 15 +++++++++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/builders/email/base_builder.rb b/app/builders/email/base_builder.rb index 6f79d6018..a6da58792 100644 --- a/app/builders/email/base_builder.rb +++ b/app/builders/email/base_builder.rb @@ -41,7 +41,7 @@ class Email::BaseBuilder end def business_name - inbox.business_name || inbox.sanitized_name + inbox.sanitized_business_name end def account_support_email diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index d9e6ec8e0..20e194fed 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -105,7 +105,7 @@ class ConversationReplyMailer < ApplicationMailer end def business_name - @inbox.business_name || @inbox.sanitized_name + @inbox.sanitized_business_name end def from_email diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 0a26462ad..82b250560 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -102,7 +102,7 @@ class Inbox < ApplicationRecord # Sanitizes inbox name for balanced email provider compatibility # ALLOWS: /'._- and Unicode letters/numbers/emojis - # REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~) + # REMOVES: Forbidden chars (\<>@"()) + spam-trigger symbols (!#$%&*+=?^`{|}~) def sanitized_name return default_name_for_blank_name if name.blank? @@ -110,6 +110,10 @@ class Inbox < ApplicationRecord sanitized.blank? && email? ? display_name_from_email : sanitized end + def sanitized_business_name + sanitize_raw_name(business_name) || sanitized_name + end + def sms? channel_type == 'Channel::Sms' end @@ -209,8 +213,15 @@ class Inbox < ApplicationRecord email? ? display_name_from_email : '' end + def sanitize_raw_name(raw) + return nil if raw.blank? + + result = apply_sanitization_rules(raw) + result.presence + end + def apply_sanitization_rules(name) - name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars + name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;()]/, '') # Remove forbidden chars .gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces .gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation .gsub(/\s+/, ' ') # Normalize spaces From d2625d85443c1cdead6d84c6fc1b69ebbb37dfcd Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:48:03 +0530 Subject: [PATCH 05/24] fix: TypeError cannot read properties of null (reading 'name') (#14112) --- .../routes/dashboard/conversation/ConversationAction.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue index 718a990bb..8543968fe 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue @@ -125,7 +125,7 @@ export default { set(priorityItem) { const conversationId = this.currentChat.id; const oldValue = this.currentChat?.priority; - const priority = priorityItem ? priorityItem.id : null; + const priority = priorityItem.id; this.$store.dispatch('setCurrentChatPriority', { priority, @@ -203,7 +203,9 @@ export default { this.assignedPriority && this.assignedPriority.id === selectedPriorityItem.id; - this.assignedPriority = isSamePriority ? null : selectedPriorityItem; + this.assignedPriority = isSamePriority + ? this.priorityOptions[0] + : selectedPriorityItem; }, }, }; From 6928f14a762e5ae40b74d5901fb7685c46ac4ee2 Mon Sep 17 00:00:00 2001 From: Gabor Barany <23645175+gbarany@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:44:25 +0100 Subject: [PATCH 06/24] fix: Validate Twilio webhook signatures (X-Twilio-Signature) (#13638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #13619 ## Summary - Add `TwilioSignatureVerifyConcern` that validates the `X-Twilio-Signature` header using `Twilio::Security::RequestValidator` (already bundled via `twilio-ruby` gem) - Include the concern in `Twilio::CallbackController` and `Twilio::DeliveryStatusController` — both endpoints were previously accepting requests from any source with no authentication - Channels using API key authentication (`api_key_sid` present) skip validation with a warning log, since Twilio signs with the account auth token which isn't stored for those channels ## How it works 1. `before_action` looks up the `Channel::TwilioSms` from request params (`MessagingServiceSid` or `AccountSid` + phone number) 2. Validates the HMAC-SHA1 signature using the channel's auth token 3. Returns `403 Forbidden` if signature is invalid, missing, or channel not found 4. Handles reverse proxy URL reconstruction via `X-Forwarded-Proto` header Follows the same pattern used by `Webhooks::ShopifyController` and `Webhooks::TiktokController`. ## Test plan - [x] Valid signature → 204 No Content, job enqueued - [x] Invalid signature → 403 Forbidden, job not enqueued - [x] Missing signature header → 403 Forbidden - [x] Channel not found → 403 Forbidden - [x] API key channel → skips validation, job enqueued (with warning log) - [x] MessagingServiceSid lookup → validates and enqueues - [x] All existing Twilio service/job specs pass (99 examples, 0 failures) --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Muhsin Keloth Co-authored-by: Sojan Jose --- .../twilio_signature_verify_concern.rb | 88 ++++++++++ app/controllers/twilio/callback_controller.rb | 2 + .../twilio/delivery_status_controller.rb | 6 + .../twilio/callbacks_controller_spec.rb | 151 +++++++++++++++++- .../twilio/delivery_status_controller_spec.rb | 125 ++++++++++++++- 5 files changed, 356 insertions(+), 16 deletions(-) create mode 100644 app/controllers/concerns/twilio_signature_verify_concern.rb diff --git a/app/controllers/concerns/twilio_signature_verify_concern.rb b/app/controllers/concerns/twilio_signature_verify_concern.rb new file mode 100644 index 000000000..b7a4754a4 --- /dev/null +++ b/app/controllers/concerns/twilio_signature_verify_concern.rb @@ -0,0 +1,88 @@ +module TwilioSignatureVerifyConcern + extend ActiveSupport::Concern + + included do + before_action :verify_twilio_signature! + end + + private + + def verify_twilio_signature! + channel = find_twilio_channel + return log_and_reject_missing_channel if channel.blank? + return if channel.api_key_sid.present? && log_api_key_skip(channel) + + head :forbidden unless valid_signature?(channel) + end + + def log_and_reject_missing_channel + Rails.logger.warn( + '[TWILIO] Channel not found for webhook ' \ + "account_sid=#{params[:AccountSid]} messaging_service_sid=#{params[:MessagingServiceSid]} " \ + "to=#{params[:To]} from=#{params[:From]}" + ) + head :forbidden + end + + def log_api_key_skip(channel) + Rails.logger.warn( + '[TWILIO] Signature validation skipped: channel uses API key authentication. ' \ + "account_sid=#{params[:AccountSid]} channel_id=#{channel.id}" + ) + end + + def valid_signature?(channel) + signature = request.headers['X-Twilio-Signature'] + if signature.blank? + Rails.logger.warn("[TWILIO] Missing X-Twilio-Signature header account_sid=#{params[:AccountSid]}") + return false + end + + validator = Twilio::Security::RequestValidator.new(channel.auth_token) + request_url = reconstruct_url + return true if validator.validate(request_url, request.request_parameters, signature) + + Rails.logger.warn( + '[TWILIO] Signature validation failed ' \ + "account_sid=#{params[:AccountSid]} channel_id=#{channel.id} url=#{request_url} ip=#{request.remote_ip}" + ) + false + end + + def find_twilio_channel + if params[:MessagingServiceSid].present? + channel = ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) + return channel if channel.present? && (params[:AccountSid].blank? || channel.account_sid == params[:AccountSid]) + + return nil + end + return if params[:AccountSid].blank? + + find_channel_by_phone_number + end + + def find_channel_by_phone_number + channel_lookup_phone_numbers.each do |phone| + channel = ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: phone) + return channel if channel + end + nil + end + + def channel_lookup_phone_numbers + [params[:To], params[:From]].compact_blank + end + + def reconstruct_url + url = request.original_url + url = url.sub('http://', 'https://') if url.start_with?('http://') && https_request? + url + end + + def https_request? + return true if request.ssl? + + forwarded_proto = request.headers['X-Forwarded-Proto'].to_s.split(',').map(&:strip).find(&:present?) + forwarded_proto&.casecmp?('https') + end +end diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index d607ba151..9b42cd034 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -1,4 +1,6 @@ class Twilio::CallbackController < ApplicationController + include TwilioSignatureVerifyConcern + def create Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash) diff --git a/app/controllers/twilio/delivery_status_controller.rb b/app/controllers/twilio/delivery_status_controller.rb index 1c756a1c2..8e846a737 100644 --- a/app/controllers/twilio/delivery_status_controller.rb +++ b/app/controllers/twilio/delivery_status_controller.rb @@ -1,4 +1,6 @@ class Twilio::DeliveryStatusController < ApplicationController + include TwilioSignatureVerifyConcern + def create Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash) @@ -18,4 +20,8 @@ class Twilio::DeliveryStatusController < ApplicationController :ErrorMessage ) end + + def channel_lookup_phone_numbers + [params[:From]].compact_blank + end end diff --git a/spec/controllers/twilio/callbacks_controller_spec.rb b/spec/controllers/twilio/callbacks_controller_spec.rb index d16acf229..09b558096 100644 --- a/spec/controllers/twilio/callbacks_controller_spec.rb +++ b/spec/controllers/twilio/callbacks_controller_spec.rb @@ -4,25 +4,160 @@ RSpec.describe 'Twilio::CallbacksController', type: :request do include Rails.application.routes.url_helpers describe 'POST /twilio/callback' do + let(:account) { create(:account) } + let(:twilio_channel) { create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123') } let(:params) do { 'From' => '+1234567890', - 'To' => '+0987654321', + 'To' => twilio_channel.phone_number, 'Body' => 'Test message', 'AccountSid' => 'AC123', 'SmsSid' => 'SM123' } end - it 'enqueues the Twilio events job' do - expect do - post twilio_callback_index_url, params: params - end.to have_enqueued_job(Webhooks::TwilioEventsJob).with(params) + def post_with_signature(url, params:) + validator = Twilio::Security::RequestValidator.new(twilio_channel.auth_token) + signature = validator.build_signature_for(url, params) + post url, params: params, headers: { 'X-Twilio-Signature' => signature } end - it 'returns no content status' do - post twilio_callback_index_url, params: params - expect(response).to have_http_status(:no_content) + context 'with valid signature' do + it 'enqueues the Twilio events job' do + url = twilio_callback_index_url + expect do + post_with_signature(url, params: params) + end.to have_enqueued_job(Webhooks::TwilioEventsJob) + end + + it 'returns no content status' do + url = twilio_callback_index_url + post_with_signature(url, params: params) + expect(response).to have_http_status(:no_content) + end + end + + context 'with invalid signature' do + it 'returns forbidden status' do + post twilio_callback_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' } + expect(response).to have_http_status(:forbidden) + end + + it 'does not enqueue the job' do + expect do + post twilio_callback_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' } + end.not_to have_enqueued_job(Webhooks::TwilioEventsJob) + end + end + + context 'with missing signature header' do + it 'returns forbidden status' do + post twilio_callback_index_url, params: params + expect(response).to have_http_status(:forbidden) + end + end + + context 'when channel is not found' do + it 'returns forbidden status' do + post twilio_callback_index_url, params: params.merge('AccountSid' => 'UNKNOWN', 'To' => '+0000000000') + expect(response).to have_http_status(:forbidden) + end + end + + context 'when channel uses API key authentication' do + let(:twilio_channel) do + create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123', api_key_sid: 'SK123') + end + + it 'skips signature validation and enqueues the job' do + expect do + post twilio_callback_index_url, params: params + end.to have_enqueued_job(Webhooks::TwilioEventsJob) + end + end + + context 'when behind a reverse proxy with X-Forwarded-Proto' do + it 'validates signature against the HTTPS URL' do + http_url = twilio_callback_index_url + https_url = http_url.sub('http://', 'https://') + validator = Twilio::Security::RequestValidator.new(twilio_channel.auth_token) + signature = validator.build_signature_for(https_url, params) + post http_url, params: params, headers: { + 'X-Twilio-Signature' => signature, + 'X-Forwarded-Proto' => 'https' + } + expect(response).to have_http_status(:no_content) + end + + it 'validates signature when forwarded proto is a comma-separated chain' do + http_url = twilio_callback_index_url + https_url = http_url.sub('http://', 'https://') + validator = Twilio::Security::RequestValidator.new(twilio_channel.auth_token) + signature = validator.build_signature_for(https_url, params) + post http_url, params: params, headers: { + 'X-Twilio-Signature' => signature, + 'X-Forwarded-Proto' => 'https,http' + } + expect(response).to have_http_status(:no_content) + end + end + + context 'with MessagingServiceSid lookup' do + let(:twilio_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC123') } + let(:params) do + { + 'From' => '+1234567890', + 'Body' => 'Test message', + 'AccountSid' => 'AC123', + 'SmsSid' => 'SM123', + 'MessagingServiceSid' => twilio_channel.messaging_service_sid + } + end + + it 'validates and enqueues the job' do + url = twilio_callback_index_url + post_with_signature(url, params: params) + expect(response).to have_http_status(:no_content) + end + end + + context 'when MessagingServiceSid is present but does not match a channel' do + let(:params) do + { + 'From' => '+1234567890', + 'To' => twilio_channel.phone_number, + 'Body' => 'Test message', + 'AccountSid' => 'AC123', + 'SmsSid' => 'SM123', + 'MessagingServiceSid' => 'MG_UNKNOWN' + } + end + + it 'returns forbidden without falling back to phone number lookup' do + url = twilio_callback_index_url + post_with_signature(url, params: params) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when MessagingServiceSid matches a channel but AccountSid does not' do + let(:other_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC_OTHER') } + let(:params) do + { + 'From' => '+1234567890', + 'To' => twilio_channel.phone_number, + 'Body' => 'Test message', + 'AccountSid' => 'AC123', + 'SmsSid' => 'SM123', + 'MessagingServiceSid' => other_channel.messaging_service_sid + } + end + + it 'returns forbidden without falling back to phone number lookup' do + url = twilio_callback_index_url + post_with_signature(url, params: params) + expect(response).to have_http_status(:forbidden) + end end end end diff --git a/spec/controllers/twilio/delivery_status_controller_spec.rb b/spec/controllers/twilio/delivery_status_controller_spec.rb index fc21f8f94..dc99fdf23 100644 --- a/spec/controllers/twilio/delivery_status_controller_spec.rb +++ b/spec/controllers/twilio/delivery_status_controller_spec.rb @@ -4,23 +4,132 @@ RSpec.describe 'Twilio::DeliveryStatusController', type: :request do include Rails.application.routes.url_helpers describe 'POST /twilio/delivery_status' do + let(:account) { create(:account) } + let(:twilio_channel) { create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123') } let(:params) do { 'MessageSid' => 'SM123', 'MessageStatus' => 'delivered', - 'AccountSid' => 'AC123' + 'AccountSid' => 'AC123', + 'From' => twilio_channel.phone_number } end - it 'enqueues the Twilio delivery status job' do - expect do - post twilio_delivery_status_index_url, params: params - end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob).with(params) + def post_with_signature(url, params:, channel: twilio_channel) + validator = Twilio::Security::RequestValidator.new(channel.auth_token) + signature = validator.build_signature_for(url, params) + post url, params: params, headers: { 'X-Twilio-Signature' => signature } end - it 'returns no content status' do - post twilio_delivery_status_index_url, params: params - expect(response).to have_http_status(:no_content) + context 'with valid signature' do + it 'enqueues the delivery status job' do + url = twilio_delivery_status_index_url + expect do + post_with_signature(url, params: params) + end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob) + end + + it 'returns no content status' do + url = twilio_delivery_status_index_url + post_with_signature(url, params: params) + expect(response).to have_http_status(:no_content) + end + end + + context 'with invalid signature' do + it 'returns forbidden status' do + post twilio_delivery_status_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' } + expect(response).to have_http_status(:forbidden) + end + + it 'does not enqueue the job' do + expect do + post twilio_delivery_status_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' } + end.not_to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob) + end + end + + context 'with missing signature header' do + it 'returns forbidden status' do + post twilio_delivery_status_index_url, params: params + expect(response).to have_http_status(:forbidden) + end + end + + context 'when channel uses API key authentication' do + let(:twilio_channel) do + create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123', api_key_sid: 'SK123') + end + + it 'skips signature validation and enqueues the job' do + expect do + post twilio_delivery_status_index_url, params: params + end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob) + end + end + + context 'with MessagingServiceSid lookup' do + let(:twilio_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC123') } + let(:params) do + { + 'MessageSid' => 'SM123', + 'MessageStatus' => 'delivered', + 'AccountSid' => 'AC123', + 'MessagingServiceSid' => twilio_channel.messaging_service_sid + } + end + + it 'validates and enqueues the job' do + url = twilio_delivery_status_index_url + post_with_signature(url, params: params) + expect(response).to have_http_status(:no_content) + end + end + + context 'when To does not map to a channel but From does' do + let(:params) do + { + 'MessageSid' => 'SM123', + 'MessageStatus' => 'delivered', + 'AccountSid' => 'AC123', + 'To' => '+19999999999', + 'From' => twilio_channel.phone_number + } + end + + it 'falls back to From lookup and enqueues the delivery status job' do + url = twilio_delivery_status_index_url + + expect do + post_with_signature(url, params: params) + end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob) + + expect(response).to have_http_status(:no_content) + end + end + + context 'when To maps to an API-key channel but From maps to a different channel' do + let!(:api_key_channel) do + create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123', api_key_sid: 'SK123') + end + let!(:from_channel) { create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123') } + let(:params) do + { + 'MessageSid' => 'SM123', + 'MessageStatus' => 'delivered', + 'AccountSid' => 'AC123', + 'To' => api_key_channel.phone_number, + 'From' => from_channel.phone_number + } + end + + it 'rejects invalid signatures instead of skipping verification' do + expect do + post twilio_delivery_status_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' } + end.not_to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob) + + expect(response).to have_http_status(:forbidden) + end end end end From 6a9c44476e2293542c036bffe845c7bb4160ce47 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 21 Apr 2026 15:55:12 +0400 Subject: [PATCH 07/24] feat(super-admin): Add push diagnostics tool (#14105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We're getting many customer reports saying "I'm not getting notifications." We can't always identify the root cause since there are multiple points of failure. Added a **Push Diagnostics** tool in Super Admin to help us investigate mobile/web push issues. Here's how it works: - Look up a user by email/ID → see all their registered subscriptions with device info (iOS/Android, brand, model), token freshness, and last-updated time - Send a customizable test push and read the raw FCM/web-push/relay response to see if the customer is receiving push notifications—if not, it will show proper errors. - Delete broken subscriptions so the mobile app re-registers on next launch CleanShot 2026-04-20 at 12 56
56@2x Fixes https://linear.app/chatwoot/issue/CW-6892/push-diagnostics-tool --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- .../push_diagnostics_controller.rb | 68 +++++++ .../notification/push_test_service.rb | 160 +++++++++++++++ .../application/_navigation.html.erb | 3 +- .../push_diagnostics/show.html.erb | 190 ++++++++++++++++++ config/locales/en.yml | 6 + config/routes.rb | 3 + lib/chatwoot_hub.rb | 8 +- 7 files changed, 435 insertions(+), 3 deletions(-) create mode 100644 app/controllers/super_admin/push_diagnostics_controller.rb create mode 100644 app/services/notification/push_test_service.rb create mode 100644 app/views/super_admin/push_diagnostics/show.html.erb diff --git a/app/controllers/super_admin/push_diagnostics_controller.rb b/app/controllers/super_admin/push_diagnostics_controller.rb new file mode 100644 index 000000000..c33cfdc1e --- /dev/null +++ b/app/controllers/super_admin/push_diagnostics_controller.rb @@ -0,0 +1,68 @@ +class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController + def show + @query = params[:user_query].to_s.strip + @user = resolve_user(@query) + @subscriptions = @user ? @user.notification_subscriptions.order(:id) : [] + @results = [] + end + + def create + @user = User.find_by(id: params[:user_id]) + return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil? + + ids = parsed_subscription_ids + if ids.empty? + return redirect_to super_admin_push_diagnostics_path(user_query: @user.id), + alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test') + end + + run_test_and_render(ids) + end + + def destroy_subscriptions + user = User.find_by(id: params[:user_id]) + return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil? + + ids = parsed_subscription_ids + if ids.empty? + return redirect_to super_admin_push_diagnostics_path(user_query: user.id), + alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete') + end + + deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size + log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}") + redirect_to super_admin_push_diagnostics_path(user_query: user.id), + notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count) + end + + private + + def run_test_and_render(ids) + @query = @user.id.to_s + @subscriptions = @user.notification_subscriptions.order(:id) + @results = Notification::PushTestService.new( + user: @user, subscription_ids: ids, + title: params[:push_title], body: params[:push_body] + ).perform + + log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}") + render :show + end + + def log_super_admin_action(message) + Rails.logger.info( + "[SuperAdmin] push diagnostics #{message} " \ + "(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})" + ) + end + + def resolve_user(query) + return if query.blank? + + query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query) + end + + def parsed_subscription_ids + Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i) + end +end diff --git a/app/services/notification/push_test_service.rb b/app/services/notification/push_test_service.rb new file mode 100644 index 000000000..4b2bec177 --- /dev/null +++ b/app/services/notification/push_test_service.rb @@ -0,0 +1,160 @@ +class Notification::PushTestService + pattr_initialize [:user!, :subscription_ids!, :title, :body] + + DEFAULT_TITLE = '%s notification test'.freeze + DEFAULT_BODY = 'This is a test from our team to check notification delivery on your device. No action needed.'.freeze + + def self.default_title + format(DEFAULT_TITLE, installation_name: GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot')) + end + + def self.default_body + DEFAULT_BODY + end + + def perform + selected_subscriptions.map { |subscription| test_send(subscription) } + end + + private + + def resolved_title + title.presence || self.class.default_title + end + + def resolved_body + body.presence || self.class.default_body + end + + def selected_subscriptions + user.notification_subscriptions.where(id: subscription_ids).order(:id) + end + + def test_send(subscription) + if subscription.browser_push? + test_browser_push(subscription) + elsif subscription.fcm? + test_fcm(subscription) + else + result(subscription, subscription.subscription_type.to_s, :skipped, 'Unknown subscription type') + end + end + + def test_browser_push(subscription) + return result(subscription, 'browser_push', :skipped, 'VAPID keys not configured') unless VapidService.public_key + + WebPush.payload_send(**browser_push_payload(subscription)) + result(subscription, 'browser_push', :success, 'Web push accepted by endpoint') + rescue StandardError => e + result(subscription, 'browser_push', :failure, "#{e.class.name}: #{e.message}") + end + + def test_fcm(subscription) + if firebase_credentials_present? + test_fcm_direct(subscription) + elsif chatwoot_hub_enabled? + test_fcm_via_hub(subscription) + else + result(subscription, 'fcm', :skipped, 'No Firebase credentials and push relay disabled') + end + end + + def test_fcm_direct(subscription) + fcm_service = Notification::FcmService.new( + GlobalConfigService.load('FIREBASE_PROJECT_ID', nil), + GlobalConfigService.load('FIREBASE_CREDENTIALS', nil) + ) + response = fcm_service.fcm_client.send_v1(fcm_options(subscription)) + status_code = response[:status_code].to_i + status = status_code.between?(200, 299) ? :success : :failure + result(subscription, 'fcm', status, "HTTP #{status_code} — #{response[:body]}") + rescue StandardError => e + result(subscription, 'fcm', :failure, "#{e.class.name}: #{e.message}") + end + + def test_fcm_via_hub(subscription) + response = ChatwootHub.send_push_with_response(fcm_options(subscription)) + result(subscription, 'fcm_via_hub', :success, "HTTP #{response.code} — #{response.body}") + rescue RestClient::ExceptionWithResponse => e + result(subscription, 'fcm_via_hub', :failure, "HTTP #{e.response&.code} — #{e.response&.body}") + rescue StandardError => e + result(subscription, 'fcm_via_hub', :failure, "#{e.class.name}: #{e.message}") + end + + def firebase_credentials_present? + GlobalConfigService.load('FIREBASE_PROJECT_ID', nil) && GlobalConfigService.load('FIREBASE_CREDENTIALS', nil) + end + + def chatwoot_hub_enabled? + ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true)) + end + + def browser_push_payload(subscription) + { + message: JSON.generate( + title: resolved_title, + tag: "super_admin_test_#{Time.zone.now.to_i}", + url: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com') + ), + endpoint: subscription.subscription_attributes['endpoint'], + p256dh: subscription.subscription_attributes['p256dh'], + auth: subscription.subscription_attributes['auth'], + vapid: { + subject: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com'), + public_key: VapidService.public_key, + private_key: VapidService.private_key + }, + ssl_timeout: 5, + open_timeout: 5, + read_timeout: 5 + } + end + + def fcm_options(subscription) + { + 'token': subscription.subscription_attributes['push_token'], + 'data': { payload: { data: { notification: { type: 'test' } } }.to_json }, + 'notification': { title: resolved_title, body: resolved_body }, + 'android': { priority: 'high' }, + 'apns': { payload: { aps: { sound: 'default', category: Time.zone.now.to_i.to_s } } }, + 'fcm_options': { analytics_label: 'SuperAdminTest' } + } + end + + def result(subscription, type, status, message) + attrs = subscription.subscription_attributes || {} + { + id: subscription.id, + type: type.to_s, + device: device_label(subscription, attrs), + token_tail: token_tail(subscription, attrs), + status: status, + message: message + } + end + + def device_label(subscription, attrs) + if subscription.browser_push? + endpoint_host(attrs['endpoint'].to_s) + else + attrs['device_id'].present? ? "…#{attrs['device_id'].to_s.last(6)}" : '—' + end + end + + def endpoint_host(endpoint) + return '—' if endpoint.blank? + + URI.parse(endpoint).host.presence || endpoint + rescue URI::InvalidURIError + endpoint + end + + def token_tail(subscription, attrs) + if subscription.browser_push? + endpoint = attrs['endpoint'].to_s + endpoint.present? ? "…#{endpoint.last(6)}" : '—' + else + attrs['push_token'].present? ? "…#{attrs['push_token'].to_s.last(6)}" : '—' + end + end +end diff --git a/app/views/super_admin/application/_navigation.html.erb b/app/views/super_admin/application/_navigation.html.erb index 787576d33..da3a024f8 100644 --- a/app/views/super_admin/application/_navigation.html.erb +++ b/app/views/super_admin/application/_navigation.html.erb @@ -32,7 +32,7 @@ as defined by the routes in the `admin/` namespace
    <%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %> <% Administrate::Namespace.new(namespace).resources.each do |resource| %> - <% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings"].include? resource.resource %> + <% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %> <%= render partial: "nav_item", locals: { icon: sidebar_icons[resource.resource.to_sym], url: resource_index_route(resource), @@ -48,6 +48,7 @@ as defined by the routes in the `admin/` namespace
      <%= render partial: "nav_item", locals: { icon: 'icon-mist-fill', url: sidekiq_web_url, label: 'Sidekiq Dashboard' } %> <%= render partial: "nav_item", locals: { icon: 'icon-health-book-line', url: super_admin_instance_status_url, label: 'Instance Health' } %> + <%= render partial: "nav_item", locals: { icon: 'icon-mail-send-fill', url: super_admin_push_diagnostics_url, label: 'Push Diagnostics' } %> <%= render partial: "nav_item", locals: { icon: 'icon-dashboard-line', url: '/', label: 'Agent Dashboard' } %> <%= render partial: "nav_item", locals: { icon: 'icon-logout-circle-r-line', url: super_admin_logout_url, label: 'Logout' } %>
    diff --git a/app/views/super_admin/push_diagnostics/show.html.erb b/app/views/super_admin/push_diagnostics/show.html.erb new file mode 100644 index 000000000..736a77cd8 --- /dev/null +++ b/app/views/super_admin/push_diagnostics/show.html.erb @@ -0,0 +1,190 @@ +<% content_for(:title) do %>Push Diagnostics<% end %> + + + +
    +

    + Send a test push notification to a specific user's registered devices to diagnose delivery issues. + Results show the raw FCM / Web Push / relay response so you can see exactly what failed. +

    + +
    +

    1. Look up user

    + <%= form_with url: super_admin_push_diagnostics_path, method: :get, local: true, class: 'flex gap-2 items-center' do |f| %> + <%= f.text_field :user_query, + value: @query, + placeholder: 'user@example.com or numeric user ID', + class: 'border border-slate-100 p-1.5 rounded-md w-80' %> + <%= f.submit 'Look up', class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer' %> + <% end %> + <% if @query.present? && @user.nil? %> +

    No user found for "<%= @query %>".

    + <% end %> +
    + + <% if @user %> +
    +

    + <%= @user.name %> + · <%= @user.email %> + · ID <%= @user.id %> +

    + <% if @user.accounts.any? %> +

    + Accounts: + <% @user.accounts.each_with_index do |account, index| %> + <%= ', ' if index.positive? %> + <%= account.name %> (ID <%= account.id %>) + <% end %> +

    + <% end %> +
    + +
    +

    2. Select subscriptions (<%= @subscriptions.count %>)

    +

    + ⚠️ This sends a real push to every selected device. Use only when diagnosing a reported issue. +

    + + <% if @subscriptions.empty? %> +

    This user has no push subscriptions registered.

    + <% else %> + <%= form_with url: super_admin_push_diagnostics_path, method: :post, local: true do |f| %> + <%= f.hidden_field :user_id, value: @user.id %> +
    +
    + <%= label_tag :push_title, 'Title', class: 'block text-xs font-medium text-slate-600 mb-1' %> + <%= text_field_tag :push_title, + params[:push_title].presence || Notification::PushTestService.default_title, + class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %> +
    +
    + <%= label_tag :push_body, 'Body', class: 'block text-xs font-medium text-slate-600 mb-1' %> + <%= text_area_tag :push_body, + params[:push_body].presence || Notification::PushTestService.default_body, + rows: 2, + class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %> +
    +

    Customers will see this text as a real push notification on their device.

    +
    + + + + + + + + + + + + + + + <% @subscriptions.each do |sub| %> + <% attrs = (sub.subscription_attributes || {}).stringify_keys %> + <% if sub.browser_push? %> + <% endpoint = attrs['endpoint'].to_s %> + <% host = (begin; URI.parse(endpoint).host; rescue URI::InvalidURIError; nil; end) %> + <% device_display = host.presence || endpoint.presence || '—' %> + <% token_display = endpoint.present? ? "…#{endpoint.last(6)}" : '—' %> + <% else %> + <% device_display = attrs['device_id'].present? ? "…#{attrs['device_id'].to_s.last(6)}" : '—' %> + <% token_display = attrs['push_token'].present? ? "…#{attrs['push_token'].to_s.last(6)}" : '—' %> + <% end %> + <% extra_attrs = attrs.except('endpoint', 'p256dh', 'auth', 'push_token', 'device_id') %> + + + + + + + + + + + <% end %> + +
    IDTypeDevice / endpointPush tokenDevice detailsCreatedLast updated
    + <%= check_box_tag 'subscription_ids[]', sub.id, false, class: 'subscription-checkbox' %> + <%= sub.id %><%= sub.subscription_type %><%= device_display %><%= token_display %> + <% if extra_attrs.present? %> + <% extra_attrs.each do |k, v| %> +
    <%= k %>: <%= v.to_s.truncate(40) %>
    + <% end %> + <% else %> + + <% end %> +
    <%= sub.created_at.strftime('%Y-%m-%d %H:%M') %> + <%= sub.updated_at.strftime('%Y-%m-%d %H:%M') %> + (<%= time_ago_in_words(sub.updated_at) %> ago) +
    +
    + <%= f.submit 'Send Test Push to Selected', + class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer font-medium' %> + <%= submit_tag 'Delete Selected Subscriptions', + formaction: destroy_subscriptions_super_admin_push_diagnostics_path, + formmethod: 'post', + data: { confirm: "Delete the selected subscription(s)? The user won't receive pushes on those devices until their app re-registers." }, + class: 'border border-red-200 bg-red-50 text-red-700 px-3 py-1.5 rounded-md cursor-pointer font-medium' %> +
    + <% end %> + <% end %> +
    + + <% if @results.present? %> +
    +

    3. Results

    + + + + + + + + + + + + + <% @results.each do |r| %> + + + + + + + + + <% end %> + +
    Sub IDTypeDevicePush tokenStatusDetails
    <%= r[:id] %><%= r[:type] %><%= r[:device] %><%= r[:token_tail] %> + <% + color = { + success: 'bg-green-100 text-green-800', + failure: 'bg-red-100 text-red-800', + skipped: 'bg-slate-100 text-slate-600' + }[r[:status]] + %> + <%= r[:status] %> + <%= r[:message] %>
    +
    + <% end %> + <% end %> +
    + +<% content_for :javascript do %> + +<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 057f41b81..1841db332 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -496,3 +496,9 @@ en: subject: 'Finish setting up %{custom_domain}' ssl_status: custom_domain_not_configured: 'Custom domain is not configured' + super_admin: + push_diagnostics: + user_not_found: 'User not found.' + no_subscriptions_to_test: 'Select at least one subscription to test.' + no_subscriptions_to_delete: 'Select at least one subscription to delete.' + subscriptions_deleted: "Deleted %{count} subscription(s). The user's device(s) will re-register on next app launch." diff --git a/config/routes.rb b/config/routes.rb index 31453b157..2461539ad 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -625,6 +625,9 @@ Rails.application.routes.draw do root to: 'dashboard#index' resource :app_config, only: [:show, :create] + resource :push_diagnostics, only: [:show, :create] do + post :destroy_subscriptions, on: :collection + end # order of resources affect the order of sidebar navigation in super admin resources :accounts, only: [:index, :new, :create, :show, :edit, :update, :destroy] do diff --git a/lib/chatwoot_hub.rb b/lib/chatwoot_hub.rb index be5a07e05..95679ed73 100644 --- a/lib/chatwoot_hub.rb +++ b/lib/chatwoot_hub.rb @@ -106,14 +106,18 @@ class ChatwootHub end def self.send_push(fcm_options) - info = { fcm_options: fcm_options } - RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) + send_push_with_response(fcm_options) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e Rails.logger.error "Exception: #{e.message}" rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception end + def self.send_push_with_response(fcm_options) + info = { fcm_options: fcm_options } + RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) + end + def self.emit_event(event_name, event_data) return if ENV['DISABLE_TELEMETRY'] From ca66218cb9f94869138d1b23fb00e5bf8c079d40 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 21 Apr 2026 19:16:08 +0400 Subject: [PATCH 08/24] Revert: Validate Twilio webhook signatures (X-Twilio-Signature) (#14125) Reverts chatwoot/chatwoot#13638 --- .../twilio_signature_verify_concern.rb | 88 ----------- app/controllers/twilio/callback_controller.rb | 2 - .../twilio/delivery_status_controller.rb | 6 - .../twilio/callbacks_controller_spec.rb | 149 +----------------- .../twilio/delivery_status_controller_spec.rb | 123 +-------------- 5 files changed, 14 insertions(+), 354 deletions(-) delete mode 100644 app/controllers/concerns/twilio_signature_verify_concern.rb diff --git a/app/controllers/concerns/twilio_signature_verify_concern.rb b/app/controllers/concerns/twilio_signature_verify_concern.rb deleted file mode 100644 index b7a4754a4..000000000 --- a/app/controllers/concerns/twilio_signature_verify_concern.rb +++ /dev/null @@ -1,88 +0,0 @@ -module TwilioSignatureVerifyConcern - extend ActiveSupport::Concern - - included do - before_action :verify_twilio_signature! - end - - private - - def verify_twilio_signature! - channel = find_twilio_channel - return log_and_reject_missing_channel if channel.blank? - return if channel.api_key_sid.present? && log_api_key_skip(channel) - - head :forbidden unless valid_signature?(channel) - end - - def log_and_reject_missing_channel - Rails.logger.warn( - '[TWILIO] Channel not found for webhook ' \ - "account_sid=#{params[:AccountSid]} messaging_service_sid=#{params[:MessagingServiceSid]} " \ - "to=#{params[:To]} from=#{params[:From]}" - ) - head :forbidden - end - - def log_api_key_skip(channel) - Rails.logger.warn( - '[TWILIO] Signature validation skipped: channel uses API key authentication. ' \ - "account_sid=#{params[:AccountSid]} channel_id=#{channel.id}" - ) - end - - def valid_signature?(channel) - signature = request.headers['X-Twilio-Signature'] - if signature.blank? - Rails.logger.warn("[TWILIO] Missing X-Twilio-Signature header account_sid=#{params[:AccountSid]}") - return false - end - - validator = Twilio::Security::RequestValidator.new(channel.auth_token) - request_url = reconstruct_url - return true if validator.validate(request_url, request.request_parameters, signature) - - Rails.logger.warn( - '[TWILIO] Signature validation failed ' \ - "account_sid=#{params[:AccountSid]} channel_id=#{channel.id} url=#{request_url} ip=#{request.remote_ip}" - ) - false - end - - def find_twilio_channel - if params[:MessagingServiceSid].present? - channel = ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) - return channel if channel.present? && (params[:AccountSid].blank? || channel.account_sid == params[:AccountSid]) - - return nil - end - return if params[:AccountSid].blank? - - find_channel_by_phone_number - end - - def find_channel_by_phone_number - channel_lookup_phone_numbers.each do |phone| - channel = ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: phone) - return channel if channel - end - nil - end - - def channel_lookup_phone_numbers - [params[:To], params[:From]].compact_blank - end - - def reconstruct_url - url = request.original_url - url = url.sub('http://', 'https://') if url.start_with?('http://') && https_request? - url - end - - def https_request? - return true if request.ssl? - - forwarded_proto = request.headers['X-Forwarded-Proto'].to_s.split(',').map(&:strip).find(&:present?) - forwarded_proto&.casecmp?('https') - end -end diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index 9b42cd034..d607ba151 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -1,6 +1,4 @@ class Twilio::CallbackController < ApplicationController - include TwilioSignatureVerifyConcern - def create Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash) diff --git a/app/controllers/twilio/delivery_status_controller.rb b/app/controllers/twilio/delivery_status_controller.rb index 8e846a737..1c756a1c2 100644 --- a/app/controllers/twilio/delivery_status_controller.rb +++ b/app/controllers/twilio/delivery_status_controller.rb @@ -1,6 +1,4 @@ class Twilio::DeliveryStatusController < ApplicationController - include TwilioSignatureVerifyConcern - def create Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash) @@ -20,8 +18,4 @@ class Twilio::DeliveryStatusController < ApplicationController :ErrorMessage ) end - - def channel_lookup_phone_numbers - [params[:From]].compact_blank - end end diff --git a/spec/controllers/twilio/callbacks_controller_spec.rb b/spec/controllers/twilio/callbacks_controller_spec.rb index 09b558096..d16acf229 100644 --- a/spec/controllers/twilio/callbacks_controller_spec.rb +++ b/spec/controllers/twilio/callbacks_controller_spec.rb @@ -4,160 +4,25 @@ RSpec.describe 'Twilio::CallbacksController', type: :request do include Rails.application.routes.url_helpers describe 'POST /twilio/callback' do - let(:account) { create(:account) } - let(:twilio_channel) { create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123') } let(:params) do { 'From' => '+1234567890', - 'To' => twilio_channel.phone_number, + 'To' => '+0987654321', 'Body' => 'Test message', 'AccountSid' => 'AC123', 'SmsSid' => 'SM123' } end - def post_with_signature(url, params:) - validator = Twilio::Security::RequestValidator.new(twilio_channel.auth_token) - signature = validator.build_signature_for(url, params) - post url, params: params, headers: { 'X-Twilio-Signature' => signature } - end - - context 'with valid signature' do - it 'enqueues the Twilio events job' do - url = twilio_callback_index_url - expect do - post_with_signature(url, params: params) - end.to have_enqueued_job(Webhooks::TwilioEventsJob) - end - - it 'returns no content status' do - url = twilio_callback_index_url - post_with_signature(url, params: params) - expect(response).to have_http_status(:no_content) - end - end - - context 'with invalid signature' do - it 'returns forbidden status' do - post twilio_callback_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' } - expect(response).to have_http_status(:forbidden) - end - - it 'does not enqueue the job' do - expect do - post twilio_callback_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' } - end.not_to have_enqueued_job(Webhooks::TwilioEventsJob) - end - end - - context 'with missing signature header' do - it 'returns forbidden status' do + it 'enqueues the Twilio events job' do + expect do post twilio_callback_index_url, params: params - expect(response).to have_http_status(:forbidden) - end + end.to have_enqueued_job(Webhooks::TwilioEventsJob).with(params) end - context 'when channel is not found' do - it 'returns forbidden status' do - post twilio_callback_index_url, params: params.merge('AccountSid' => 'UNKNOWN', 'To' => '+0000000000') - expect(response).to have_http_status(:forbidden) - end - end - - context 'when channel uses API key authentication' do - let(:twilio_channel) do - create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123', api_key_sid: 'SK123') - end - - it 'skips signature validation and enqueues the job' do - expect do - post twilio_callback_index_url, params: params - end.to have_enqueued_job(Webhooks::TwilioEventsJob) - end - end - - context 'when behind a reverse proxy with X-Forwarded-Proto' do - it 'validates signature against the HTTPS URL' do - http_url = twilio_callback_index_url - https_url = http_url.sub('http://', 'https://') - validator = Twilio::Security::RequestValidator.new(twilio_channel.auth_token) - signature = validator.build_signature_for(https_url, params) - post http_url, params: params, headers: { - 'X-Twilio-Signature' => signature, - 'X-Forwarded-Proto' => 'https' - } - expect(response).to have_http_status(:no_content) - end - - it 'validates signature when forwarded proto is a comma-separated chain' do - http_url = twilio_callback_index_url - https_url = http_url.sub('http://', 'https://') - validator = Twilio::Security::RequestValidator.new(twilio_channel.auth_token) - signature = validator.build_signature_for(https_url, params) - post http_url, params: params, headers: { - 'X-Twilio-Signature' => signature, - 'X-Forwarded-Proto' => 'https,http' - } - expect(response).to have_http_status(:no_content) - end - end - - context 'with MessagingServiceSid lookup' do - let(:twilio_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC123') } - let(:params) do - { - 'From' => '+1234567890', - 'Body' => 'Test message', - 'AccountSid' => 'AC123', - 'SmsSid' => 'SM123', - 'MessagingServiceSid' => twilio_channel.messaging_service_sid - } - end - - it 'validates and enqueues the job' do - url = twilio_callback_index_url - post_with_signature(url, params: params) - expect(response).to have_http_status(:no_content) - end - end - - context 'when MessagingServiceSid is present but does not match a channel' do - let(:params) do - { - 'From' => '+1234567890', - 'To' => twilio_channel.phone_number, - 'Body' => 'Test message', - 'AccountSid' => 'AC123', - 'SmsSid' => 'SM123', - 'MessagingServiceSid' => 'MG_UNKNOWN' - } - end - - it 'returns forbidden without falling back to phone number lookup' do - url = twilio_callback_index_url - post_with_signature(url, params: params) - expect(response).to have_http_status(:forbidden) - end - end - - context 'when MessagingServiceSid matches a channel but AccountSid does not' do - let(:other_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC_OTHER') } - let(:params) do - { - 'From' => '+1234567890', - 'To' => twilio_channel.phone_number, - 'Body' => 'Test message', - 'AccountSid' => 'AC123', - 'SmsSid' => 'SM123', - 'MessagingServiceSid' => other_channel.messaging_service_sid - } - end - - it 'returns forbidden without falling back to phone number lookup' do - url = twilio_callback_index_url - post_with_signature(url, params: params) - expect(response).to have_http_status(:forbidden) - end + it 'returns no content status' do + post twilio_callback_index_url, params: params + expect(response).to have_http_status(:no_content) end end end diff --git a/spec/controllers/twilio/delivery_status_controller_spec.rb b/spec/controllers/twilio/delivery_status_controller_spec.rb index dc99fdf23..fc21f8f94 100644 --- a/spec/controllers/twilio/delivery_status_controller_spec.rb +++ b/spec/controllers/twilio/delivery_status_controller_spec.rb @@ -4,132 +4,23 @@ RSpec.describe 'Twilio::DeliveryStatusController', type: :request do include Rails.application.routes.url_helpers describe 'POST /twilio/delivery_status' do - let(:account) { create(:account) } - let(:twilio_channel) { create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123') } let(:params) do { 'MessageSid' => 'SM123', 'MessageStatus' => 'delivered', - 'AccountSid' => 'AC123', - 'From' => twilio_channel.phone_number + 'AccountSid' => 'AC123' } end - def post_with_signature(url, params:, channel: twilio_channel) - validator = Twilio::Security::RequestValidator.new(channel.auth_token) - signature = validator.build_signature_for(url, params) - post url, params: params, headers: { 'X-Twilio-Signature' => signature } - end - - context 'with valid signature' do - it 'enqueues the delivery status job' do - url = twilio_delivery_status_index_url - expect do - post_with_signature(url, params: params) - end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob) - end - - it 'returns no content status' do - url = twilio_delivery_status_index_url - post_with_signature(url, params: params) - expect(response).to have_http_status(:no_content) - end - end - - context 'with invalid signature' do - it 'returns forbidden status' do - post twilio_delivery_status_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' } - expect(response).to have_http_status(:forbidden) - end - - it 'does not enqueue the job' do - expect do - post twilio_delivery_status_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' } - end.not_to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob) - end - end - - context 'with missing signature header' do - it 'returns forbidden status' do + it 'enqueues the Twilio delivery status job' do + expect do post twilio_delivery_status_index_url, params: params - expect(response).to have_http_status(:forbidden) - end + end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob).with(params) end - context 'when channel uses API key authentication' do - let(:twilio_channel) do - create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123', api_key_sid: 'SK123') - end - - it 'skips signature validation and enqueues the job' do - expect do - post twilio_delivery_status_index_url, params: params - end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob) - end - end - - context 'with MessagingServiceSid lookup' do - let(:twilio_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC123') } - let(:params) do - { - 'MessageSid' => 'SM123', - 'MessageStatus' => 'delivered', - 'AccountSid' => 'AC123', - 'MessagingServiceSid' => twilio_channel.messaging_service_sid - } - end - - it 'validates and enqueues the job' do - url = twilio_delivery_status_index_url - post_with_signature(url, params: params) - expect(response).to have_http_status(:no_content) - end - end - - context 'when To does not map to a channel but From does' do - let(:params) do - { - 'MessageSid' => 'SM123', - 'MessageStatus' => 'delivered', - 'AccountSid' => 'AC123', - 'To' => '+19999999999', - 'From' => twilio_channel.phone_number - } - end - - it 'falls back to From lookup and enqueues the delivery status job' do - url = twilio_delivery_status_index_url - - expect do - post_with_signature(url, params: params) - end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob) - - expect(response).to have_http_status(:no_content) - end - end - - context 'when To maps to an API-key channel but From maps to a different channel' do - let!(:api_key_channel) do - create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123', api_key_sid: 'SK123') - end - let!(:from_channel) { create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123') } - let(:params) do - { - 'MessageSid' => 'SM123', - 'MessageStatus' => 'delivered', - 'AccountSid' => 'AC123', - 'To' => api_key_channel.phone_number, - 'From' => from_channel.phone_number - } - end - - it 'rejects invalid signatures instead of skipping verification' do - expect do - post twilio_delivery_status_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' } - end.not_to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob) - - expect(response).to have_http_status(:forbidden) - end + it 'returns no content status' do + post twilio_delivery_status_index_url, params: params + expect(response).to have_http_status(:no_content) end end end From f12118a3c07d8de30a710357b84f18dddacbed0b Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:21:26 +0700 Subject: [PATCH 09/24] fix: void topup invoice when card payment fails (#14104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a credit top-up's card charge fails, the finalized invoice was left in an open state with no hook back into our fulfillment service. If that invoice was later paid (manually via the hosted invoice page, or by a future dunning flow), the account would never receive its credits — silent revenue loss. This change voids the invoice the moment the charge fails, so a failed top-up cannot turn into a paid-but-unfulfilled invoice. ## Closes ## How to test 1. On a Business-plan account with a Stripe customer, attach a test card that will decline on charge (e.g. `4000 0000 0000 0002`). 2. From the dashboard, open the credit top-up flow and purchase a credit pack. 3. Observe the API returns an error and the account's captain credits are unchanged. 4. In the Stripe dashboard, confirm the corresponding invoice is in `void` status (not `open`). 5. Repeat with a good card (`4242 4242 4242 4242`) and confirm the happy path still fulfills credits. ## What changed - `finalize_and_pay` in `Enterprise::Billing::TopupCheckoutService` now rescues `Stripe::CardError`, voids the open invoice via `Stripe::Invoice.void_invoice`, and re-raises so the controller surfaces the original decline error to the client. - Rescue is intentionally narrow to `Stripe::CardError` (declines, insufficient funds, SCA `authentication_required`). Transient errors like `APIConnectionError` / `RateLimitError` are left to propagate — the charge may have actually succeeded and voiding could be wrong. - Invoices are created with `auto_advance: false`, so Stripe's Smart Retries won't collect on an open invoice; voiding is the correct terminal state. Co-authored-by: Claude Opus 4.7 (1M context) --- .../services/enterprise/billing/topup_checkout_service.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb index d0ec3e372..00e2b1646 100644 --- a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb +++ b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb @@ -79,7 +79,12 @@ class Enterprise::Billing::TopupCheckoutService def finalize_and_pay(invoice_id) Stripe::Invoice.finalize_invoice(invoice_id, { auto_advance: false }) invoice = Stripe::Invoice.retrieve(invoice_id) - Stripe::Invoice.pay(invoice_id) unless invoice.status == 'paid' + return if invoice.status == 'paid' + + Stripe::Invoice.pay(invoice_id) + rescue Stripe::CardError + Stripe::Invoice.void_invoice(invoice_id) + raise end def fulfill_credits(credits, topup_option) From 475db8531817ab23e56a8d1e38cad38d60f15fee Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:13:26 +0530 Subject: [PATCH 10/24] fix: prevent template picker dropdown cut-off in compose form (#14129) --- .../NewConversation/ComposeConversation.vue | 1 + .../components/ActionButtons.vue | 2 +- .../components/ContentTemplateForm.vue | 4 +- .../components/ContentTemplateSelector.vue | 120 ++++++++++-------- .../components/WhatsAppOptions.vue | 111 ++++++++-------- .../components/WhatsappTemplate.vue | 4 +- .../components-next/popover/Popover.vue | 28 +++- .../modules/contact/ContactDeleteModal.vue | 4 +- .../modules/contact/ContactMergeModal.vue | 4 +- 9 files changed, 154 insertions(+), 124 deletions(-) diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue index 1ab370901..02a00c703 100644 --- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue +++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue @@ -233,6 +233,7 @@ onMounted(() => resetContacts()); diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index aec05a717..e8f484997 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -20,6 +20,7 @@ const props = defineProps({ isEmailOrWebWidgetInbox: { type: Boolean, default: false }, isTwilioSmsInbox: { type: Boolean, default: false }, isTwilioWhatsAppInbox: { type: Boolean, default: false }, + // eslint-disable-next-line vue/no-unused-properties messageTemplates: { type: Array, default: () => [] }, channelType: { type: String, default: '' }, isLoading: { type: Boolean, default: false }, @@ -198,7 +199,6 @@ useEventListener(document, 'paste', onPaste); { diff --git a/config/locales/en.yml b/config/locales/en.yml index 36f115bab..9ffd3f3d5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -493,6 +493,7 @@ en: locale_not_available: 'Locale not available in this portal' category_not_found: 'Category not found in this portal' no_articles_found: 'No articles found to process' + invalid_status: 'Invalid status value' send_instructions: email_required: 'Email is required' invalid_email_format: 'Invalid email format' diff --git a/config/routes.rb b/config/routes.rb index c6111d317..a1d3d088e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -361,6 +361,8 @@ Rails.application.routes.draw do namespace :articles do resource :bulk_actions, only: [] do post :translate + patch :update_status + delete :delete_articles end end resources :articles do diff --git a/spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb b/spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb new file mode 100644 index 000000000..3dab5b60f --- /dev/null +++ b/spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb @@ -0,0 +1,141 @@ +require 'rails_helper' + +RSpec.describe 'Article Bulk Actions API', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let!(:portal) { create(:portal, name: 'test_portal', account: account, config: { allowed_locales: %w[en es] }) } + let!(:category) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') } + let!(:article_one) { create(:article, category: category, portal: portal, account: account, author: admin, status: :draft) } + let!(:article_two) { create(:article, category: category, portal: portal, account: account, author: admin, status: :draft) } + let!(:article_three) { create(:article, category: category, portal: portal, account: account, author: admin, status: :published) } + + let(:base_url) { "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/bulk_actions" } + + describe 'PATCH articles/bulk_actions/update_status' do + let(:update_status_url) { "#{base_url}/update_status" } + + context 'when unauthenticated' do + it 'returns unauthorized' do + patch update_status_url, params: { ids: [article_one.id], status: 'published' }, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as agent' do + it 'returns unauthorized' do + patch update_status_url, + headers: agent.create_new_auth_token, + params: { ids: [article_one.id], status: 'published' }, + as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as admin' do + it 'publishes multiple articles' do + patch update_status_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id, article_two.id], status: 'published' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(article_one.reload.status).to eq('published') + expect(article_two.reload.status).to eq('published') + end + + it 'archives multiple articles' do + patch update_status_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id, article_three.id], status: 'archived' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(article_one.reload.status).to eq('archived') + expect(article_three.reload.status).to eq('archived') + end + + it 'sets articles to draft' do + patch update_status_url, + headers: admin.create_new_auth_token, + params: { ids: [article_three.id], status: 'draft' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(article_three.reload.status).to eq('draft') + end + + it 'does not affect articles not in the list' do + patch update_status_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id], status: 'published' }, + as: :json + + expect(article_one.reload.status).to eq('published') + expect(article_three.reload.status).to eq('published') + end + + it 'returns unprocessable entity when no articles found' do + patch update_status_url, + headers: admin.create_new_auth_token, + params: { ids: [0], status: 'published' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + describe 'DELETE articles/bulk_actions/delete_articles' do + let(:destroy_url) { "#{base_url}/delete_articles" } + + context 'when unauthenticated' do + it 'returns unauthorized' do + delete destroy_url, params: { ids: [article_one.id] }, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as agent' do + it 'returns unauthorized' do + delete destroy_url, + headers: agent.create_new_auth_token, + params: { ids: [article_one.id] }, + as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as admin' do + it 'deletes multiple articles' do + expect do + delete destroy_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id, article_two.id] }, + as: :json + end.to change(Article, :count).by(-2) + + expect(response).to have_http_status(:ok) + end + + it 'does not delete articles not in the list' do + delete destroy_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id] }, + as: :json + + expect(Article.exists?(article_one.id)).to be(false) + expect(Article.exists?(article_three.id)).to be(true) + end + + it 'returns unprocessable entity when no articles found' do + delete destroy_url, + headers: admin.create_new_auth_token, + params: { ids: [0] }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end +end From a651949c33383b57d08e21d2210916b1ae2e9363 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:01:44 +0530 Subject: [PATCH 21/24] fix: improve FAQ generation [AI-145] (#14062) # Pull Request Template ## Description - Fetch main content only from Firecrawl, exclude some tags to remove boilerplate - Prompt changes for FAQ generation ## 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. tested locally ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --- .../captain/documents/response_builder_job.rb | 2 +- enterprise/app/models/captain/document.rb | 4 +++ .../captain/llm/faq_generator_service.rb | 16 ++++++++---- .../llm/paginated_faq_generator_service.rb | 14 +++++------ .../captain/llm/system_prompts_service.rb | 25 ++++++++++++------- .../captain/tools/firecrawl_service.rb | 17 ++++++++----- .../documents/response_builder_job_spec.rb | 13 +++------- .../captain/llm/faq_generator_service_spec.rb | 11 ++++---- .../captain/tools/firecrawl_service_spec.rb | 4 +-- 9 files changed, 62 insertions(+), 44 deletions(-) diff --git a/enterprise/app/jobs/captain/documents/response_builder_job.rb b/enterprise/app/jobs/captain/documents/response_builder_job.rb index 60f1fa2cf..8f6643e36 100644 --- a/enterprise/app/jobs/captain/documents/response_builder_job.rb +++ b/enterprise/app/jobs/captain/documents/response_builder_job.rb @@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob end def generate_standard_faqs(document) - Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate + Captain::Llm::FaqGeneratorService.new(document: document).generate end def build_paginated_service(document, options) diff --git a/enterprise/app/models/captain/document.rb b/enterprise/app/models/captain/document.rb index 2abf18437..c2b5fa214 100644 --- a/enterprise/app/models/captain/document.rb +++ b/enterprise/app/models/captain/document.rb @@ -117,6 +117,10 @@ class Captain::Document < ApplicationRecord end end + def to_llm_metadata + { document_id: id, assistant_id: assistant_id, external_link: external_link } + end + private def enqueue_crawl_job diff --git a/enterprise/app/services/captain/llm/faq_generator_service.rb b/enterprise/app/services/captain/llm/faq_generator_service.rb index 5f85ae467..b80382b3e 100644 --- a/enterprise/app/services/captain/llm/faq_generator_service.rb +++ b/enterprise/app/services/captain/llm/faq_generator_service.rb @@ -1,11 +1,12 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService include Integrations::LlmInstrumentation - def initialize(content, language = 'english', account_id: nil) + def initialize(document:) super() - @language = language - @content = content - @account_id = account_id + @document = document + @content = document.content + @language = document.account.locale_english_name + @account_id = document.account_id end def generate @@ -40,10 +41,15 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService messages: [ { role: 'system', content: system_prompt }, { role: 'user', content: @content } - ] + ], + metadata: document_metadata } end + def document_metadata + @document&.to_llm_metadata || {} + end + def parse_response(content) return [] if content.nil? diff --git a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb index 3fe81c2ae..b567609e8 100644 --- a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb +++ b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb @@ -51,7 +51,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService account_id: @document&.account_id, feature_name: 'faq_generation', model: @model, - messages: params[:messages] + messages: params[:messages], + metadata: document_metadata } response = instrument_llm_call(instrumentation_params) do @@ -214,12 +215,11 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService feature_name: 'paginated_faq_generation', model: @model, messages: params[:messages], - metadata: { - document_id: @document&.id, - start_page: start_page, - end_page: end_page, - iteration: @iterations_completed + 1 - } + metadata: document_metadata.merge(start_page: start_page, end_page: end_page, iteration: @iterations_completed + 1) } end + + def document_metadata + @document&.to_llm_metadata || {} + end end diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb index 9868f0360..dab147301 100644 --- a/enterprise/app/services/captain/llm/system_prompts_service.rb +++ b/enterprise/app/services/captain/llm/system_prompts_service.rb @@ -3,11 +3,15 @@ class Captain::Llm::SystemPromptsService class << self def faq_generator(language = 'english') <<~PROMPT - You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any information. + You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any substantive information. ## Core Requirements - **Completeness**: Extract ALL information from the source content. Every detail, example, procedure, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the original content entirely. + **Completeness**: Extract ALL substantive information from the source content. Every detail, example, procedure, warning, code block, identifier, limit, definition, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the substantive source content entirely. + + **Self-contained answers**: Every answer must contain the information that answers its question. The answer must be the substance, not directions to where the substance lives. If a source section provides only a reference, link, or pointer to where the information can be found — without containing that information itself — omit the FAQ for that section. An FAQ whose answer redirects the reader is worse than no FAQ at all. + + **Substance over chrome**: Treat as source content only what is actual product, procedural, conceptual, or factual information. Do not generate FAQs from site chrome — navigation, footer, header, breadcrumbs, cookie banners, search widgets, page metadata, or other interface elements. **Accuracy**: Base answers strictly on the provided text. Do not add assumptions, interpretations, or external knowledge not present in the source material. @@ -29,18 +33,21 @@ class Captain::Llm::SystemPromptsService ## Guidelines - **Question Creation**: Formulate questions that naturally arise from the content (What is...? How do I...? When should...? Why does...?). Do not generate questions that are not related to the content. - - **Answer Completeness**: Include all relevant details, steps, examples, and context from the original content - - **Information Preservation**: Ensure no examples, procedures, warnings, or explanatory details are omitted + - **Answer Completeness**: Include all relevant details, steps, examples, code, identifiers, limits, and definitions present in the source. + - **Information Preservation**: Never omit examples, procedures, warnings, code, IDs, limits, or definitions in the name of brevity. + - **No Deflecting FAQs**: Do not create FAQs whose answer would only tell the reader to open another link, guide, or document. If the source contains useful factual content in link text, labels, lists, or summaries (e.g., a curated list of supported integrations, plan features, resources, or article indexes), preserve that content as the answer. If it only points elsewhere without providing the answer itself, skip it. - **JSON Validity**: Always return properly formatted, valid JSON - **No Content Scenario**: If no suitable content is found, return: `{"faqs": []}` ## Process 1. Read the entire provided content carefully - 2. Identify all key information points, procedures, and examples - 3. Create questions that cover each information point - 4. Write comprehensive short answers that capture all related detail, include bullet points if needed. - 5. Verify that combined FAQs represent the complete original content. - 6. Format as valid JSON + 2. Identify all key information points: procedures, examples, code, identifiers, limits, definitions, warnings, and explanations + 3. For each candidate section, verify the source contains the substance that would answer the question. If the source only points to where the substance lives, skip the section. + 4. Disregard interface chrome (navigation, footer, header, cookie banners, breadcrumbs, page metadata). + 5. Create questions that cover each remaining substantive information point + 6. Write self-contained answers that preserve all relevant details from the source. Be concise where possible, but never trade away steps, examples, warnings, code, IDs, limits, or definitions for brevity. + 7. Verify the combined FAQs represent the complete substantive source content (excluding redirect-only sections and chrome). + 8. Format as valid JSON PROMPT end diff --git a/enterprise/app/services/captain/tools/firecrawl_service.rb b/enterprise/app/services/captain/tools/firecrawl_service.rb index fc7448593..3d1b53b7a 100644 --- a/enterprise/app/services/captain/tools/firecrawl_service.rb +++ b/enterprise/app/services/captain/tools/firecrawl_service.rb @@ -1,5 +1,6 @@ class Captain::Tools::FirecrawlService BASE_URL = 'https://api.firecrawl.dev/v1'.freeze + FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze def initialize @api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value @@ -33,16 +34,20 @@ class Captain::Tools::FirecrawlService ignoreSitemap: false, limit: crawl_limit, webhook: webhook_url, - scrapeOptions: { - onlyMainContent: false, - formats: ['markdown'], - excludeTags: ['iframe'] - } + scrapeOptions: scrape_options }.to_json end def scrape_payload(url) - { url: url, formats: ['markdown'], excludeTags: ['iframe'] }.to_json + { url: url }.merge(scrape_options).to_json + end + + def scrape_options + { + onlyMainContent: true, + formats: ['markdown'], + excludeTags: FIRECRAWL_EXCLUDE_TAGS + } end def headers diff --git a/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb index c3e5eab1c..4d1a07aa7 100644 --- a/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb @@ -12,9 +12,7 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do end before do - allow(Captain::Llm::FaqGeneratorService).to receive(:new) - .with(document.content, document.account.locale_english_name, account_id: document.account_id) - .and_return(faq_generator) + allow(Captain::Llm::FaqGeneratorService).to receive(:new).with(document: document).and_return(faq_generator) allow(faq_generator).to receive(:generate).and_return(faqs) end @@ -51,17 +49,14 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do let(:spanish_faq_generator) { instance_double(Captain::Llm::FaqGeneratorService) } before do - allow(Captain::Llm::FaqGeneratorService).to receive(:new) - .with(spanish_document.content, 'portuguese', account_id: spanish_document.account_id) - .and_return(spanish_faq_generator) + allow(Captain::Llm::FaqGeneratorService).to receive(:new).with(document: spanish_document).and_return(spanish_faq_generator) allow(spanish_faq_generator).to receive(:generate).and_return(faqs) end - it 'passes the correct locale to FAQ generator' do + it 'passes the correct document to FAQ generator' do described_class.new.perform(spanish_document) - expect(Captain::Llm::FaqGeneratorService).to have_received(:new) - .with(spanish_document.content, 'portuguese', account_id: spanish_document.account_id) + expect(Captain::Llm::FaqGeneratorService).to have_received(:new).with(document: spanish_document) end end diff --git a/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb b/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb index 003d5b715..ff7138c9a 100644 --- a/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb +++ b/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb @@ -2,8 +2,8 @@ require 'rails_helper' RSpec.describe Captain::Llm::FaqGeneratorService do let(:content) { 'Sample content for FAQ generation' } - let(:language) { 'english' } - let(:service) { described_class.new(content, language) } + let(:document) { create(:captain_document, content: content) } + let(:service) { described_class.new(document: document) } let(:mock_chat) { instance_double(RubyLLM::Chat) } let(:sample_faqs) do [ @@ -36,14 +36,15 @@ RSpec.describe Captain::Llm::FaqGeneratorService do service.generate end - it 'uses SystemPromptsService with the specified language' do - expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(language).at_least(:once).and_call_original + it 'uses SystemPromptsService with the account language' do + account_language = document.account.locale_english_name + expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(account_language).at_least(:once).and_call_original service.generate end end context 'with different language' do - let(:language) { 'spanish' } + before { allow(document.account).to receive(:locale_english_name).and_return('spanish') } it 'passes the correct language to SystemPromptsService' do expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with('spanish').at_least(:once).and_call_original diff --git a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb index d6563b163..b46633a6e 100644 --- a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb +++ b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb @@ -58,9 +58,9 @@ RSpec.describe Captain::Tools::FirecrawlService do limit: crawl_limit, webhook: webhook_url, scrapeOptions: { - onlyMainContent: false, + onlyMainContent: true, formats: ['markdown'], - excludeTags: ['iframe'] + excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS } }.to_json end From 8faa5a74b1ce27248f15aa2bce9a2c6c56d227d7 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:30:51 +0530 Subject: [PATCH 22/24] fix: prevent focus jump to title after new article auto-creates (#14145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description When creating a help center article, typing a title and navigating into the content auto-creates the article and switches the route (`/articles/new` → `/articles/.../edit/:slug`). During this transition, focus was jumping back to the title, interrupting editing. This happened because `ArticleEditor` always autofocuses the title. On route change, the component remounts and re-triggers focus. Now, after auto-create, focus stays in the body as expected. Fixes https://linear.app/chatwoot/issue/CW-6951/issue-with-the-cursor-position-on-the-help-center-article-when ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Screencast** https://github.com/user-attachments/assets/dac3f7c6-08c4-4df2-afb0-7731ee76424b ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue index 22cb1441a..831312e0b 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue @@ -121,7 +121,7 @@ const handleCreateArticle = event => { custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]" custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0" placeholder="Title" - autofocus + :autofocus="isNewArticle" @blur="handleCreateArticle" /> { t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER') " :enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS" - :autofocus="false" + :autofocus="!isNewArticle" /> From 06467057be17d900450f8eac98cb7845a65bdf29 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:31:43 +0530 Subject: [PATCH 23/24] fix: oversized email signature images in Letter render (#14144) # Pull Request Template ## Description This PR fixes an issue where signature images (with `?cw_image_height=...`) render at their original large size in the email bubble. ### Cause Renderer output: ```html ``` Email UI and clients (Gmail, Outlook) apply CSS like: `img { max-width: 100%; height: auto; }` This overrides `height="24px"`. Other channels work because they use inline styles (`style="height: 24px;"`). ### Solution Use inline style instead: ```html ``` ### Why backend fix * Fixes root cause and aligns Ruby + JS renderers * Works in both Chatwoot UI and recipient inboxes * Covers all email-rendered content * Minimal change Fixes https://linear.app/chatwoot/issue/CW-6948/email-signature-image-renders-oversized-in-chatwoot-ui ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? #### Screenshots **Before** image **After** image ## 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 --- lib/base_markdown_renderer.rb | 8 ++++++-- spec/lib/base_markdown_renderer_spec.rb | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/base_markdown_renderer.rb b/lib/base_markdown_renderer.rb index df49918b3..f530e71ee 100644 --- a/lib/base_markdown_renderer.rb +++ b/lib/base_markdown_renderer.rb @@ -29,11 +29,15 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer def render_img_tag(src, title, height = nil) title_attribute = title.present? ? " title=\"#{title}\"" : '' - height_attribute = height ? " height=\"#{height}\" width=\"auto\"" : '' + # Use inline style instead of the HTML height attribute: email clients and + # the in-app Letter view both run images through CSS (e.g. prose / + # lettersanitizer's `img { height: auto }`) which overrides presentational + # attributes. Inline style has higher specificity and survives. + style_attribute = height ? " style=\"height: #{height};\"" : '' plain do # plain ensures that the content is not wrapped in a paragraph tag - out("") + out("") end end end diff --git a/spec/lib/base_markdown_renderer_spec.rb b/spec/lib/base_markdown_renderer_spec.rb index 262e78daf..f8bdae4be 100644 --- a/spec/lib/base_markdown_renderer_spec.rb +++ b/spec/lib/base_markdown_renderer_spec.rb @@ -12,7 +12,7 @@ describe BaseMarkdownRenderer do context 'when image has a height' do it 'renders the img tag with the correct attributes' do markdown = '![Sample Title](https://example.com/image.jpg?cw_image_height=100)' - expect(render_markdown(markdown)).to include('') + expect(render_markdown(markdown)).to include('') end end From 0920a01e662163c13714fd6bd393b292df89151d Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 27 Apr 2026 15:40:00 +0530 Subject: [PATCH 24/24] fix(i18n): align pluralization with locale rules (#14266) Loads Rails locale-specific pluralization rules so languages with an `other`-only plural model can safely use Crowdin exports without maintaining duplicate `one` keys. ## Closes None ## Why Crowdin exports Rails YAML pluralized strings using each target language's plural categories. These categories come from Unicode CLDR and represent grammatical forms, not a literal "number is 1" bucket. Some languages need separate forms such as `one` and `other`, but languages like Japanese, Korean, Indonesian, Thai, Vietnamese, and Chinese use the same form for `1`, `2`, `5`, and larger counts in these strings. For those locales, CLDR correctly models the plural category as `other` only. Before this change, Chatwoot still relied on Rails' default English-style plural behavior for these locales. That meant a valid Crowdin export containing only `other` could fail at runtime when Rails received `count: 1` and looked for a missing `one` branch. Keeping duplicate `one` keys would only fight Crowdin on every translation sync. The runtime should instead follow the locale's plural rules. ## What changed - Added `rails-i18n` and enabled only its pluralization module. - Added explicit `other`-only plural rules for Chatwoot's underscore Chinese locale aliases, `zh_CN` and `zh_TW`. - Removed redundant `one` keys from the affected Devise and `time_units` translations. ## Validation - Ran a Rails runner check across `id`, `ja`, `ko`, `ms`, `th`, `vi`, `zh_CN`, and `zh_TW` to verify `errors.messages.not_saved` and `time_units.days` resolve with only `other` for `count: 1`. - Ran YAML parse validation for all edited locale files. - Ran `bundle exec rubocop Gemfile config/application.rb config/initializers/i18n_pluralization.rb`. --- Gemfile | 1 + Gemfile.lock | 4 ++++ config/application.rb | 1 + config/initializers/i18n_pluralization.rb | 8 ++++++++ config/locales/devise.id.yml | 1 - config/locales/devise.ja.yml | 1 - config/locales/devise.ko.yml | 1 - config/locales/devise.ms.yml | 1 - config/locales/devise.th.yml | 1 - config/locales/devise.vi.yml | 1 - config/locales/devise.zh_CN.yml | 1 - config/locales/devise.zh_TW.yml | 1 - config/locales/id.yml | 4 ---- config/locales/ja.yml | 4 ---- config/locales/ko.yml | 4 ---- config/locales/ms.yml | 4 ---- config/locales/th.yml | 4 ---- config/locales/vi.yml | 4 ---- config/locales/zh_CN.yml | 4 ---- config/locales/zh_TW.yml | 4 ---- 20 files changed, 14 insertions(+), 40 deletions(-) create mode 100644 config/initializers/i18n_pluralization.rb diff --git a/Gemfile b/Gemfile index a5068e765..c4989c538 100644 --- a/Gemfile +++ b/Gemfile @@ -84,6 +84,7 @@ gem 'barnes' gem 'devise', '>= 4.9.4' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise_token_auth', '>= 1.2.3' +gem 'rails-i18n', '~> 7.0' # two-factor authentication gem 'devise-two-factor', '>= 5.0.0' # authorization diff --git a/Gemfile.lock b/Gemfile.lock index b77e5880f..7d29e0b02 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -727,6 +727,9 @@ GEM rails-html-sanitizer (1.6.1) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails-i18n (7.0.10) + i18n (>= 0.7, < 2) + railties (>= 6.0.0, < 8) railties (7.1.5.2) actionpack (= 7.1.5.2) activesupport (= 7.1.5.2) @@ -1125,6 +1128,7 @@ DEPENDENCIES rack-mini-profiler (>= 3.2.0) rack-timeout rails (~> 7.1) + rails-i18n (~> 7.0) redis redis-namespace responders (>= 3.1.1) diff --git a/config/application.rb b/config/application.rb index aa150794a..08f0451c1 100644 --- a/config/application.rb +++ b/config/application.rb @@ -37,6 +37,7 @@ module Chatwoot class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. config.load_defaults 7.0 + config.rails_i18n.enabled_modules = [:pluralization] config.eager_load_paths << Rails.root.join('lib') config.eager_load_paths << Rails.root.join('enterprise/lib') diff --git a/config/initializers/i18n_pluralization.rb b/config/initializers/i18n_pluralization.rb new file mode 100644 index 000000000..c4fc3fb4b --- /dev/null +++ b/config/initializers/i18n_pluralization.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +other_plural_rule = ->(_count) { :other } + +Rails.application.config.after_initialize do + I18n.backend.store_translations(:zh_CN, i18n: { plural: { rule: other_plural_rule } }) + I18n.backend.store_translations(:zh_TW, i18n: { plural: { rule: other_plural_rule } }) +end diff --git a/config/locales/devise.id.yml b/config/locales/devise.id.yml index 71b9f46fb..fc6bfb26a 100644 --- a/config/locales/devise.id.yml +++ b/config/locales/devise.id.yml @@ -57,5 +57,4 @@ id: not_found: "tidak ditemukan" not_locked: "tidak terkunci" not_saved: - one: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:" other: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:" diff --git a/config/locales/devise.ja.yml b/config/locales/devise.ja.yml index 043cd8351..a5840c6fc 100644 --- a/config/locales/devise.ja.yml +++ b/config/locales/devise.ja.yml @@ -57,5 +57,4 @@ ja: not_found: "見つかりませんでした" not_locked: "はロックされていません" not_saved: - one: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:" other: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:" diff --git a/config/locales/devise.ko.yml b/config/locales/devise.ko.yml index 846664ec9..5afb6c1c2 100644 --- a/config/locales/devise.ko.yml +++ b/config/locales/devise.ko.yml @@ -57,5 +57,4 @@ ko: not_found: "찾을 수 없습니다" not_locked: "잠겨 있지 않습니다" not_saved: - one: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:" other: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:" diff --git a/config/locales/devise.ms.yml b/config/locales/devise.ms.yml index ebcfe89e3..cecd08588 100644 --- a/config/locales/devise.ms.yml +++ b/config/locales/devise.ms.yml @@ -57,5 +57,4 @@ ms: not_found: "not found" not_locked: "was not locked" not_saved: - one: "%{count} errors prohibited this %{resource} from being saved:" other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/locales/devise.th.yml b/config/locales/devise.th.yml index c9f52018d..18e1572bb 100644 --- a/config/locales/devise.th.yml +++ b/config/locales/devise.th.yml @@ -57,5 +57,4 @@ th: not_found: "not found" not_locked: "was not locked" not_saved: - one: "%{count} errors prohibited this %{resource} from being saved:" other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/locales/devise.vi.yml b/config/locales/devise.vi.yml index 947e756f3..15dca044a 100644 --- a/config/locales/devise.vi.yml +++ b/config/locales/devise.vi.yml @@ -57,5 +57,4 @@ vi: not_found: "không tìm thấy" not_locked: "không được khoá" not_saved: - one: "Có %{count} lỗi được tìm thấy từ %{resource}:" other: "Có %{count} lỗi được tìm thấy từ %{resource}:" diff --git a/config/locales/devise.zh_CN.yml b/config/locales/devise.zh_CN.yml index 00f239948..2bf2831a8 100644 --- a/config/locales/devise.zh_CN.yml +++ b/config/locales/devise.zh_CN.yml @@ -57,5 +57,4 @@ zh_CN: not_found: "找不到" not_locked: "未锁定" not_saved: - one: "%{count} 个错误禁止保存 %{resource}:" other: "%{count} 个错误禁止保存 %{resource}:" diff --git a/config/locales/devise.zh_TW.yml b/config/locales/devise.zh_TW.yml index c5bd49450..f892bf796 100644 --- a/config/locales/devise.zh_TW.yml +++ b/config/locales/devise.zh_TW.yml @@ -57,5 +57,4 @@ zh_TW: not_found: "找不到。" not_locked: "並未被鎖定。" not_saved: - one: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:" other: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:" diff --git a/config/locales/id.yml b/config/locales/id.yml index aefcff3fe..b097de83d 100644 --- a/config/locales/id.yml +++ b/config/locales/id.yml @@ -435,16 +435,12 @@ id: button: Buka percakapan time_units: days: - one: '%{count} days' other: '%{count} days' hours: - one: '%{count} hours' other: '%{count} hours' minutes: - one: '%{count} minutes' other: '%{count} minutes' seconds: - one: '%{count} seconds' other: '%{count} seconds' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 5e3412378..deca85ebf 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -435,16 +435,12 @@ ja: button: 会話を開く time_units: days: - one: '%{count} 日' other: '%{count} 日' hours: - one: '%{count} 時間' other: '%{count} 時間' minutes: - one: '%{count} 分' other: '%{count} 分' seconds: - one: '%{count} 秒' other: '%{count} 秒' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/ko.yml b/config/locales/ko.yml index c010ee2ac..153f39928 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -435,16 +435,12 @@ ko: button: 대화 열기 time_units: days: - one: '%{count}일' other: '%{count}일' hours: - one: '%{count}시간' other: '%{count}시간' minutes: - one: '%{count}분' other: '%{count}분' seconds: - one: '%{count}초' other: '%{count}초' auto_assignment: default_policy_name: '기본 정책' diff --git a/config/locales/ms.yml b/config/locales/ms.yml index 617056d7e..e1ee39aee 100644 --- a/config/locales/ms.yml +++ b/config/locales/ms.yml @@ -435,16 +435,12 @@ ms: button: Open conversation time_units: days: - one: '%{count} days' other: '%{count} days' hours: - one: '%{count} hours' other: '%{count} hours' minutes: - one: '%{count} minutes' other: '%{count} minutes' seconds: - one: '%{count} seconds' other: '%{count} seconds' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/th.yml b/config/locales/th.yml index b278ec442..aeef51e95 100644 --- a/config/locales/th.yml +++ b/config/locales/th.yml @@ -435,16 +435,12 @@ th: button: เปิดดูการสนทนา time_units: days: - one: '%{count} days' other: '%{count} days' hours: - one: '%{count} hours' other: '%{count} hours' minutes: - one: '%{count} minutes' other: '%{count} minutes' seconds: - one: '%{count} seconds' other: '%{count} seconds' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/vi.yml b/config/locales/vi.yml index c53f8de4c..1facb248f 100644 --- a/config/locales/vi.yml +++ b/config/locales/vi.yml @@ -435,16 +435,12 @@ vi: button: Mở cuộc trò chuyện time_units: days: - one: '%{count} days' other: '%{count} days' hours: - one: '%{count} hours' other: '%{count} hours' minutes: - one: '%{count} minutes' other: '%{count} minutes' seconds: - one: '%{count} seconds' other: '%{count} seconds' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/zh_CN.yml b/config/locales/zh_CN.yml index 0f6cd069f..ab8005c5e 100644 --- a/config/locales/zh_CN.yml +++ b/config/locales/zh_CN.yml @@ -435,16 +435,12 @@ zh_CN: button: 重新打开会话 time_units: days: - one: '%{count} 天' other: '%{count} 天' hours: - one: '%{count} 小时' other: '%{count} 小时' minutes: - one: '%{count} 分钟' other: '%{count} 分钟' seconds: - one: '%{count} 秒' other: '%{count} 秒' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/zh_TW.yml b/config/locales/zh_TW.yml index 775bcf000..d7dd33efa 100644 --- a/config/locales/zh_TW.yml +++ b/config/locales/zh_TW.yml @@ -435,16 +435,12 @@ zh_TW: button: '開啟對話' time_units: days: - one: '%{count} 天' other: '%{count} 天' hours: - one: '%{count} 小時' other: '%{count} 小時' minutes: - one: '%{count} 分鐘' other: '%{count} 分鐘' seconds: - one: '%{count} 秒' other: '%{count} 秒' auto_assignment: default_policy_name: '預設策略'