From 7acbe8b3ff154ceee410462cf25c213501747c27 Mon Sep 17 00:00:00 2001 From: JoseGrdar <168092473+JoseGrdar@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:20:21 +0200 Subject: [PATCH 01/23] fix(whatsapp): truncate location fallback_title to 255 chars to avoid silent message drop (#14517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `Whatsapp::IncomingMessageBaseService#attach_location` builds a `fallback_title` by concatenating `location['name']` and `location['address']` with no length cap, then stores it directly into `Attachment#fallback_title`. `ApplicationRecord` enforces a generic 255-character limit on string columns, so any WhatsApp location whose `"#{name}, #{address}"` exceeds 255 chars (a common case for Google Places that include a long full address) raises `ActiveRecord::RecordInvalid` deep inside the Sidekiq job. The message and attachment INSERTs are part of the same transaction, so the whole thing rolls back. Sidekiq retries once; the retry dedup-skips the wamid silently and exits without an error. **Result: the message is irrecoverably lost — no row in `messages`, no entry in the UI, no outgoing webhook, no clue for the operator.** Confirmed in `v4.13.0`, `v4.14.0`, and `develop` (commit `f33e469`, 2026-05-20). No upstream issue found before opening this PR. ## How to reproduce 1. From WhatsApp, share a Google Place whose `name + ", " + address` is > 255 chars. The Spanish business address `Gremi de Fusters, 33, Edificio VIP Asima, Piso 2, Local 2, Norte, 07009 Polígon industrial de Son Castelló, Illes Balears, España` (132 chars) used as both `name` and `address` is enough. 2. Sidekiq logs: ``` ERROR ActiveRecord::RecordInvalid: Validation failed: Attachments fallback title is too long (maximum is 255 characters) ``` 3. The `messages` table has no row. The conversation UI shows nothing for that timestamp. 4. The first retry "Performed" successfully but creates nothing — the dedup-by-source-id silently swallows the failure. ## Fix Cap the existing concatenated title at 255 chars via `.first(255)`. Minimal change, no behavioural difference for any message shorter than the limit, prevents the silent data loss for any longer ones. ```diff - location_name = location['name'] ? "#{location['name']}, #{location['address']}" : '' + location_name = (location['name'] ? "#{location['name']}, #{location['address']}" : '').first(255) ``` ## Alternatives considered - **Increase the validation limit on `Attachment#fallback_title`**: more invasive; would touch other inbound channels and possibly require a DB column change. - **Use `name` alone (no concat)**: cleaner semantically (in many real payloads `name == address`), but changes user-visible behaviour. Left as a follow-up if desired. - **Truncate with ellipsis**: cosmetic only; deferred. This PR is intentionally minimal so it can be merged on its own. --------- Co-authored-by: Sony Mathew Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> --- .../whatsapp/incoming_message_base_service.rb | 2 +- .../whatsapp/incoming_message_service_spec.rb | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb index 722ac3e4d..9e0720f74 100644 --- a/app/services/whatsapp/incoming_message_base_service.rb +++ b/app/services/whatsapp/incoming_message_base_service.rb @@ -147,7 +147,7 @@ class Whatsapp::IncomingMessageBaseService def attach_location location = messages_data.first['location'] - location_name = location['name'] ? "#{location['name']}, #{location['address']}" : '' + location_name = (location['name'] ? "#{location['name']}, #{location['address']}" : '').first(255) @message.attachments.new( account_id: @message.account_id, file_type: file_content_type(message_type), diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb index fe6b179c1..430aa1561 100644 --- a/spec/services/whatsapp/incoming_message_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_service_spec.rb @@ -381,6 +381,32 @@ describe Whatsapp::IncomingMessageService do expect(location_attachment.coordinates_long).to eq(-122.3895553) expect(location_attachment.external_url).to eq('http://location_url.test') end + + it 'truncates long fallback titles to avoid dropping location messages' do + long_place_name = [ + 'Gremi de Fusters, 33, Edificio VIP Asima, Piso 2, Local 2, Norte', + '07009 Poligon industrial de Son Castello, Illes Balears, Espana' + ].join(', ') + source_id = 'wamid.long-location-fallback-title' + params = { + 'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }], + 'messages' => [{ 'from' => '2423423243', 'id' => source_id, + 'location' => { 'id' => 'b1c68f38-8734-4ad3-b4a1-ef0c10d683', + :address => long_place_name, + :latitude => 37.7893768, + :longitude => -122.3895553, + :name => long_place_name, + :url => 'http://location_url.test' }, + 'timestamp' => '1633034394', 'type' => 'location' }] + }.with_indifferent_access + + expect { described_class.new(inbox: whatsapp_channel.inbox, params: params).perform } + .to change { Message.where(source_id: source_id).count }.from(0).to(1) + + location_attachment = Message.find_by!(source_id: source_id).attachments.first + expect(location_attachment.fallback_title).to eq("#{long_place_name}, #{long_place_name}".first(255)) + expect(location_attachment.fallback_title.length).to eq(255) + end end context 'when valid contact message params' do From 0d59fb44590b61d1b19d3366d03a765c98f5c423 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:18:19 +0530 Subject: [PATCH 02/23] fix: validate portal color format (#14632) --- app/models/portal.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/portal.rb b/app/models/portal.rb index 41c4d3edd..37665a15c 100644 --- a/app/models/portal.rb +++ b/app/models/portal.rb @@ -42,6 +42,7 @@ class Portal < ApplicationRecord validates :name, presence: true validates :slug, presence: true, uniqueness: true validates :custom_domain, uniqueness: true, allow_nil: true + validates :color, format: { with: /\A#(?:\h{3}|\h{6})\z/ }, allow_blank: true validate :config_json_format scope :active, -> { where(archived: false) } From d028cc1984e57d18c54d90e3e940ef64f76c2feb Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:18:48 +0530 Subject: [PATCH 03/23] fix: prevent list marker overflow in messages (#14618) --- app/javascript/widget/assets/scss/woot.scss | 19 ++++++++++--- tailwind.config.js | 30 ++++++++++++--------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/app/javascript/widget/assets/scss/woot.scss b/app/javascript/widget/assets/scss/woot.scss index b47179d29..c42273215 100755 --- a/app/javascript/widget/assets/scss/woot.scss +++ b/app/javascript/widget/assets/scss/woot.scss @@ -34,13 +34,24 @@ body { .message-content { ul { - list-style: disc; - @apply ltr:pl-3 rtl:pr-3; + @apply list-disc list-inside; } ol { - list-style: decimal; - @apply ltr:pl-4 rtl:pr-4; + @apply list-decimal list-inside; + } + + li { + padding-inline-start: 1.5em; + text-indent: -1.5em; + + > p:first-child { + @apply inline; + } + + > * { + text-indent: 0; + } } } diff --git a/tailwind.config.js b/tailwind.config.js index b4c4b9078..cc2aebb63 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -106,24 +106,30 @@ const tailwindConfig = { textDecoration: 'underline', }, ul: { - paddingInlineStart: '0.625em', + paddingInlineStart: '0', + listStylePosition: 'inside', }, ol: { - paddingInlineStart: '0.625em', + paddingInlineStart: '0', + listStylePosition: 'inside', }, - 'ul li': { - margin: '0 0 0.5em 1em', + 'ul > li': { + marginBlockEnd: '0.5em', listStyleType: 'disc', - '[dir="rtl"] &': { - margin: '0 1em 0.5em 0', - }, + paddingInlineStart: '1.5em', + textIndent: '-1.5em', }, - 'ol li': { - margin: '0 0 0.5em 1em', + 'ol > li': { + marginBlockEnd: '0.5em', listStyleType: 'decimal', - '[dir="rtl"] &': { - margin: '0 1em 0.5em 0', - }, + paddingInlineStart: '1.5em', + textIndent: '-1.5em', + }, + 'li > p:first-child': { + display: 'inline', + }, + 'li > *': { + textIndent: '0', }, blockquote: { color: 'rgb(var(--slate-11))', From 1beaa284c60d2ae0313e4ca1cda70871e18a2d15 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:05:17 +0530 Subject: [PATCH 04/23] feat: inline images in website and email channels (#14516) # Pull Request Template ## Description This PR adds support for inline image uploads in the reply editor for Email and Website (chat widget) channels. Agents can now insert images inline between text and resize them directly in the editor by dragging the bottom corner, similar to the help center editor experience. Image sizes are preserved through markdown using the `cw_image_width` URL param and render correctly in both outgoing emails and chat widget messages. Agents can also paste copied images directly into Email or Website replies using **Shift+Cmd+V** (Shift+Ctrl+V on Windows/Linux). The image gets inserted inline at the cursor position and supports resizing just like uploaded images. Regular **Cmd+V / Ctrl+V** behavior remains unchanged and continues to add images as attachments, so both inline and attachment flows are supported. ### Prosemirror repo PR: https://github.com/chatwoot/prosemirror-schema/pull/48 Fixes https://linear.app/chatwoot/issue/CW-7133/inline-images-in-live-chat-and-email https://linear.app/chatwoot/issue/CW-7225/ghsa-8j9w-jppp-xcfc-html-attribute-injection-via-unvalidated-cw-image ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Screencast https://github.com/user-attachments/assets/a928f852-ab15-413a-9d35-6ea69b718ecf 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 - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- .../components/widgets/WootWriter/Editor.vue | 164 ++++++++---------- app/javascript/dashboard/constants/editor.js | 22 +-- .../dashboard/helper/editorHelper.js | 25 --- .../helper/specs/editorHelper.spec.js | 66 ------- .../i18n/locale/en/conversation.json | 1 + .../settings/profile/MessageSignature.vue | 1 - .../shared/helpers/MessageFormatter.js | 25 ++- lib/base_markdown_renderer.rb | 32 +++- package.json | 2 +- pnpm-lock.yaml | 10 +- spec/lib/base_markdown_renderer_spec.rb | 29 +++- 11 files changed, 150 insertions(+), 227 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 3da526fc0..a8f2d0a2c 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -32,7 +32,6 @@ import { CONVERSATION_EVENTS, CAPTAIN_EVENTS, } from 'dashboard/helper/AnalyticsHelper/events'; -import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor'; import { messageSchema, @@ -43,6 +42,7 @@ import { MessageMarkdownSerializer, EditorState, Selection, + imageResizeView, } from '@chatwoot/prosemirror-schema'; import { suggestionsPlugin, @@ -57,7 +57,6 @@ import { insertAtCursor, removeSignature as removeSignatureHelper, scrollCursorIntoView, - setURLWithQueryAndSize, getFormattingForEditor, getSelectionCoords, calculateMenuPosition, @@ -72,6 +71,7 @@ import { import { createTypingIndicator } from '@chatwoot/utils'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper'; +import { INBOX_TYPES } from 'dashboard/helper/inbox'; const props = defineProps({ modelValue: { type: String, default: '' }, @@ -93,7 +93,6 @@ const props = defineProps({ channelType: { type: String, default: '' }, conversationId: { type: Number, default: null }, medium: { type: String, default: '' }, - showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar focusOnMount: { type: Boolean, default: true }, }); @@ -119,6 +118,14 @@ const TYPING_INDICATOR_IDLE_TIME = 4000; const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB const DEFAULT_FORMATTING = 'Context::Default'; const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote'; +const MESSAGE_SIGNATURE_FORMATTING = 'Context::MessageSignature'; +const INLINE_IMAGE_PASTE_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/webp', +]; const effectiveChannelType = computed(() => getEffectiveChannelType(props.channelType, props.medium) @@ -192,12 +199,8 @@ const cannedSearchTerm = ref(''); const variableSearchTerm = ref(''); const emojiSearchTerm = ref(''); const range = ref(null); -const isImageNodeSelected = ref(false); -const toolbarPosition = ref({ top: 0, left: 0 }); -const selectedImageNode = ref(null); const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection const showSelectionMenu = ref(false); -const sizes = MESSAGE_EDITOR_IMAGE_RESIZES; // element ref const editorRoot = useTemplateRef('editorRoot'); @@ -475,16 +478,6 @@ function removeSignature() { reloadState(content); } -function setToolbarPosition() { - const editorRect = editorRoot.value.getBoundingClientRect(); - const rect = selectedImageNode.value.getBoundingClientRect(); - - toolbarPosition.value = { - top: `${rect.top - editorRect.top - 30}px`, - left: `${rect.left - editorRect.left - 4}px`, - }; -} - function setMenubarPosition({ selection } = {}) { const wrapper = editorRoot.value; if (!selection || !wrapper) return; @@ -520,30 +513,6 @@ function checkSelection(editorState) { if (hasSelection) setMenubarPosition(editorState); } -function setURLWithQueryAndImageSize(size) { - if (!props.showImageResizeToolbar) { - return; - } - setURLWithQueryAndSize(selectedImageNode.value, size, editorView); - isImageNodeSelected.value = false; -} - -function isEditorMouseFocusedOnAnImage() { - if (!props.showImageResizeToolbar) { - return; - } - selectedImageNode.value = document.querySelector( - 'img.ProseMirror-selectednode' - ); - if (selectedImageNode.value) { - isImageNodeSelected.value = !!selectedImageNode.value; - // Get the position of the selected node - setToolbarPosition(); - } else { - isImageNodeSelected.value = false; - } -} - function emitOnChange() { emit('input', contentFromEditor()); emit('update:modelValue', contentFromEditor()); @@ -563,21 +532,6 @@ function toggleSignatureInEditor(signatureEnabled) { emitOnChange(); } -function updateImgToolbarOnDelete() { - // check if the selected node is present or not on keyup - // this is needed because the user can select an image and then delete it - // in that case, the selected node will be null and we need to hide the toolbar - // otherwise, the toolbar will be visible even when the image is deleted and cause some errors - if (selectedImageNode.value) { - const hasImgSelectedNode = document.querySelector( - 'img.ProseMirror-selectednode' - ); - if (!hasImgSelectedNode) { - isImageNodeSelected.value = false; - } - } -} - function isEnterToSendEnabled() { return isEditorHotKeyEnabled('enter'); } @@ -586,17 +540,6 @@ function isCmdPlusEnterToSendEnabled() { return isEditorHotKeyEnabled('cmd_enter'); } -useKeyboardEvents({ - 'Alt+KeyP': { - action: focusEditorInputField, - allowOnFocusedInput: false, - }, - 'Alt+KeyL': { - action: focusEditorInputField, - allowOnFocusedInput: false, - }, -}); - function onImageInsertInEditor(fileUrl) { const { tr } = editorView.state; @@ -617,7 +560,11 @@ async function uploadImageToStorage(file) { onImageInsertInEditor(fileUrl); } useAlert( - t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS') + props.channelType === MESSAGE_SIGNATURE_FORMATTING + ? t( + 'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS' + ) + : t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS') ); } catch (error) { useAlert( @@ -626,8 +573,8 @@ async function uploadImageToStorage(file) { } } -function onFileChange() { - const file = imageUpload.value.files[0]; +function uploadImageIfWithinSizeLimit(file) { + if (!file) return; if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) { uploadImageToStorage(file); } else { @@ -640,10 +587,61 @@ function onFileChange() { ) ); } - - imageUpload.value = ''; } +function onFileChange() { + const input = imageUpload.value; + uploadImageIfWithinSizeLimit(input.files[0]); + input.value = ''; +} + +const allowsInlineImagePaste = computed( + () => + !props.isPrivate && + (props.channelType === INBOX_TYPES.EMAIL || + props.channelType === INBOX_TYPES.WEB) +); + +// Shift+Cmd/Ctrl+V on email/website: upload a clipboard image inline. This +// gesture's native paste event carries no image, so clipboard.read() is the +// only way to get the bytes. No preventDefault: text still pastes natively. +async function pasteInlineImageFromClipboard() { + if (!editorView?.hasFocus()) return; + if (!allowsInlineImagePaste.value || !navigator.clipboard?.read) return; + try { + const items = await navigator.clipboard.read(); + const imageItem = items.find(item => + item.types.some(type => INLINE_IMAGE_PASTE_TYPES.includes(type)) + ); + if (!imageItem) return; + const imageType = imageItem.types.find(type => + INLINE_IMAGE_PASTE_TYPES.includes(type) + ); + const blob = await imageItem.getType(imageType); + uploadImageIfWithinSizeLimit( + new File([blob], 'pasted-image', { type: imageType }) + ); + } catch (error) { + // clipboard-read denied/unfocused (NotAllowedError): image can't be read. + // Text paste is unaffected — ProseMirror handles it from the native event. + } +} + +useKeyboardEvents({ + 'Alt+KeyP': { + action: focusEditorInputField, + allowOnFocusedInput: false, + }, + 'Alt+KeyL': { + action: focusEditorInputField, + allowOnFocusedInput: false, + }, + '$mod+Shift+KeyV': { + action: pasteInlineImageFromClipboard, + allowOnFocusedInput: true, + }, +}); + function handleLineBreakWhenEnterToSendEnabled(event) { if ( hasPressedEnterAndNotCmdOrShift(event) && @@ -736,6 +734,9 @@ function createEditorView() { editorView = new EditorView(editor.value, { state: state, editable: () => !props.disabled, + nodeViews: { + image: imageResizeView, + }, dispatchTransaction: tx => { state = state.apply(tx); editorView.updateState(state); @@ -748,12 +749,10 @@ function createEditorView() { keyup: () => { if (!props.disabled) { typingIndicator.start(); - updateImgToolbarOnDelete(); } }, keydown: (view, event) => !props.disabled && onKeydown(event), focus: () => !props.disabled && emit('focus'), - click: () => !props.disabled && isEditorMouseFocusedOnAnImage(), blur: () => { if (props.disabled) return; typingIndicator.stop(); @@ -918,23 +917,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); @change="onFileChange" />
-
- -
diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 378d303b4..5f4fe0e03 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -14,6 +14,7 @@ export const FORMATTING = { 'link', 'bulletList', 'orderedList', + 'imageUpload', 'undo', 'redo', ], @@ -30,6 +31,7 @@ export const FORMATTING = { 'strike', 'bulletList', 'orderedList', + 'imageUpload', 'undo', 'redo', ], @@ -263,23 +265,3 @@ export const MARKDOWN_PATTERNS = [ ], }, ]; - -// Editor image resize options for Message Editor -export const MESSAGE_EDITOR_IMAGE_RESIZES = [ - { - name: 'Small', - height: '24px', - }, - { - name: 'Medium', - height: '48px', - }, - { - name: 'Large', - height: '72px', - }, - { - name: 'Original Size', - height: 'auto', - }, -]; diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index e4330b332..32f56172a 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -379,31 +379,6 @@ export const findNodeToInsertImage = (editorState, fileUrl) => { }; }; -/** - * Set URL with query and size. - * - * @param {Object} selectedImageNode - The current selected node. - * @param {Object} size - The size to set. - * @param {Object} editorView - The editor view. - */ -export function setURLWithQueryAndSize(selectedImageNode, size, editorView) { - if (selectedImageNode) { - // Create and apply the transaction - const tr = editorView.state.tr.setNodeMarkup( - editorView.state.selection.from, - null, - { - src: selectedImageNode.src, - height: size.height, - } - ); - - if (tr.docChanged) { - editorView.dispatch(tr); - } - } -} - /** * Strips unsupported markdown formatting from content based on the editor schema. * This ensures canned responses with rich formatting can be inserted into channels diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 00d1e83d4..7d32f63e0 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -16,7 +16,6 @@ import { insertAtCursor, removeSignature, replaceSignature, - setURLWithQueryAndSize, stripInlineBase64Images, stripUnsupportedFormatting, stripUnsupportedMarkdown, @@ -653,71 +652,6 @@ describe('findNodeToInsertImage', () => { }); }); -describe('setURLWithQueryAndSize', () => { - let selectedNode; - let editorView; - - beforeEach(() => { - selectedNode = { - setAttribute: vi.fn(), - }; - - const tr = { - setNodeMarkup: vi.fn().mockReturnValue({ - docChanged: true, - }), - }; - - const state = { - selection: { from: 0 }, - tr, - }; - - editorView = { - state, - dispatch: vi.fn(), - }; - }); - - it('updates the URL with the given size and updates the editor view', () => { - const size = { height: '20px' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view is updated - expect(editorView.dispatch).toHaveBeenCalledTimes(1); - }); - - it('updates the URL with the given size and updates the editor view with original size', () => { - const size = { height: 'auto' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view is updated - expect(editorView.dispatch).toHaveBeenCalledTimes(1); - }); - - it('does not update the editor view if the document has not changed', () => { - editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({ - docChanged: false, - }); - - const size = { height: '20px' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view dispatch was not called - expect(editorView.dispatch).not.toHaveBeenCalled(); - }); - - it('does not perform any operations if selectedNode is not provided', () => { - setURLWithQueryAndSize(null, { height: '20px' }, editorView); - - // Ensure the dispatch method wasn't called - expect(editorView.dispatch).not.toHaveBeenCalled(); - }); -}); - describe('getContentNode', () => { let mockEditorView; diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 7b1b49493..3eb83fd31 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -233,6 +233,7 @@ "TIP_AUDIORECORDER_ERROR": "Could not open the audio", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "DRAG_DROP": "Drag and drop here to attach", + "IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully", "START_AUDIO_RECORDING": "Start audio recording", "STOP_AUDIO_RECORDING": "Stop audio recording", "COPILOT_THINKING": "Copilot is thinking", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue index b0dab9774..bf6f01f82 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue @@ -48,7 +48,6 @@ const updateSignature = () => { :placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')" channel-type="Context::MessageSignature" :enable-suggestions="false" - show-image-resize-toolbar />
{ +const setImageSizing = inlineToken => { const imgSrc = inlineToken.attrGet('src'); if (!imgSrc) return; const url = new URL(imgSrc); + const width = url.searchParams.get('cw_image_width'); + if (width) { + inlineToken.attrSet( + 'style', + `width: ${width}; max-width: 100%; height: auto;` + ); + return; + } const height = url.searchParams.get('cw_image_height'); - if (!height) return; - inlineToken.attrSet('style', `height: ${height};`); + if (height) inlineToken.attrSet('style', `height: ${height};`); }; const processInlineToken = blockToken => { blockToken.children.forEach(inlineToken => { if (inlineToken.type === 'image') { - setImageHeight(inlineToken); + setImageSizing(inlineToken); } }); }; const imgResizeManager = md => { - // Custom rule for image resize in markdown - // If the image url has a query param cw_image_height, then add a style attribute to the image - md.core.ruler.after('inline', 'add-image-height', state => { + // If the image URL carries a cw_image_width or cw_image_height query param, + // add an inline style attribute so the rendered respects the agent's + // resize choice. Width takes precedence (HC drag-resize); height is kept for + // legacy messages and the message-signature use case. + md.core.ruler.after('inline', 'add-image-sizing', state => { state.tokens.forEach(blockToken => { if (blockToken.type === 'inline') { processInlineToken(blockToken); diff --git a/lib/base_markdown_renderer.rb b/lib/base_markdown_renderer.rb index f530e71ee..2329dea6d 100644 --- a/lib/base_markdown_renderer.rb +++ b/lib/base_markdown_renderer.rb @@ -1,9 +1,9 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer def image(node) src, title = extract_img_attributes(node) - height = extract_image_height(src) + sizing_style = extract_image_sizing_style(src) - render_img_tag(src, title, height) + render_img_tag(src, title, sizing_style) end private @@ -15,9 +15,25 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer ] end - def extract_image_height(src) + # Drag-resize from the reply editor encodes the chosen width as cw_image_width + # on the URL; the older message-signature picker uses cw_image_height. Width + # wins when both are set so the agent's most recent intent is honored. + def extract_image_sizing_style(src) query_params = parse_query_params(src) - query_params['cw_image_height']&.first + width = sanitize_pixel_value(query_params['cw_image_width']&.first) + return "width: #{width}; max-width: 100%; height: auto;" if width + + height = sanitize_pixel_value(query_params['cw_image_height']&.first) + height ? "height: #{height};" : nil + end + + # Only allow a bounded `px` value so the decoded query param can't + # break out of the inline style attribute (HTML attribute injection). + def sanitize_pixel_value(raw) + return unless raw =~ /\A(\d+)px\z/ + + px = Regexp.last_match(1).to_i + "#{px}px" if px.between?(1, 2000) end def parse_query_params(url) @@ -27,13 +43,13 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer {} end - def render_img_tag(src, title, height = nil) + def render_img_tag(src, title, sizing_style = nil) title_attribute = title.present? ? " title=\"#{title}\"" : '' - # 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 / + # Use inline style instead of HTML width/height attributes: 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};\"" : '' + style_attribute = sizing_style ? " style=\"#{sizing_style}\"" : '' plain do # plain ensures that the content is not wrapped in a paragraph tag diff --git a/package.json b/package.json index 081c706c6..d8527051d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.13", + "@chatwoot/prosemirror-schema": "1.3.17", "@chatwoot/utils": "^0.0.55", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afcf5b60f..a4b61061c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.13 - version: 1.3.13 + specifier: 1.3.17 + version: 1.3.17 '@chatwoot/utils': specifier: ^0.0.55 version: 0.0.55 @@ -458,8 +458,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.13': - resolution: {integrity: sha512-T6FBUinMJbwDCD7975g8M/Tsn2+G3O2pTGIXdcLkMRpbAAC6mVdl4ZcZektlt5y/PVmPVqNHPsfee1XB/C3vAw==} + '@chatwoot/prosemirror-schema@1.3.17': + resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==} '@chatwoot/utils@0.0.55': resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==} @@ -5128,7 +5128,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.13': + '@chatwoot/prosemirror-schema@1.3.17': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.7.1 diff --git a/spec/lib/base_markdown_renderer_spec.rb b/spec/lib/base_markdown_renderer_spec.rb index f8bdae4be..082f9fff6 100644 --- a/spec/lib/base_markdown_renderer_spec.rb +++ b/spec/lib/base_markdown_renderer_spec.rb @@ -11,8 +11,33 @@ describe BaseMarkdownRenderer do describe '#image' 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('') + markdown = '![Sample Title](https://example.com/image.jpg?cw_image_height=100px)' + expect(render_markdown(markdown)).to include('') + end + end + + context 'when image has a width' do + it 'renders the img tag with the correct attributes' do + markdown = '![Sample Title](https://example.com/image.jpg?cw_image_width=200px)' + expect(render_markdown(markdown)).to include( + '' + ) + end + end + + context 'when the sizing param contains an attribute-injection payload' do + it 'drops the malicious height value' do + markdown = '![x](https://example.com/image.jpg?cw_image_height=1px%22%20onmouseover%3D%22alert(1))' + rendered = render_markdown(markdown) + expect(rendered).not_to include('style=') + expect(rendered).not_to include('onmouseover="') + end + + it 'drops the malicious width value' do + markdown = '![x](https://example.com/image.jpg?cw_image_width=1px%22%20onmouseover%3D%22alert(1))' + rendered = render_markdown(markdown) + expect(rendered).not_to include('style=') + expect(rendered).not_to include('onmouseover="') end end From 8e42307bdcdd3d1c4f651c6da657528f28a56577 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 3 Jun 2026 15:56:54 +0530 Subject: [PATCH 05/23] fix: improve email inbox IMAP and SMTP compatibility (#14589) Fetch IMAP message content using `BODY.PEEK[]` instead of `RFC822` to avoid provider-specific parser failures while preserving unread state. This also applies the existing SMTP timeout configuration to custom SMTP email-channel replies, so provider SMTP responses have enough time to complete. Fixes: https://github.com/chatwoot/chatwoot/issues/12762 ## Why Some IMAP providers can return responses for `FETCH RFC822` that Ruby `net-imap` fails to parse with: `Net::IMAP::ResponseParseError: unexpected RPAR (expected ATOM or NIL)` We reproduced this with iCloud IMAP. Authentication, `INBOX` selection, and header fetches worked, but fetching full message content with `RFC822` failed before Chatwoot received a `Mail::Message`. The same mailbox successfully returned full message content when fetched with `BODY.PEEK[]`. > During end-to-end iCloud validation, inbound fetch worked after the IMAP change, but outbound replies through the custom SMTP settings could still fail with a socket read timeout. The OAuth SMTP path already used explicit SMTP timeout values; the custom SMTP path was relying on mailer defaults instead. ## What this change does - Replaces the full message fetch from `RFC822` to `BODY.PEEK[]` - Reads the returned message content from `BODY[]`, which is how `net-imap` exposes the response attribute - Keeps the existing `BODY.PEEK[HEADER]` header-fetch behavior unchanged - Applies `SMTP_OPEN_TIMEOUT` and `SMTP_READ_TIMEOUT` to custom SMTP email-channel replies - Defaults custom SMTP reply delivery to `open_timeout: 15` and `read_timeout: 30` - Updates IMAP service specs for standard and Microsoft IMAP fetch flows - Updates mailer specs for custom SMTP timeout settings `BODY.PEEK[]` is preferable here because it fetches the full message content without marking messages as read. ## Validation - Configured a local email inbox against iCloud IMAP and SMTP - Confirmed `FETCH RFC822` reproduces `Net::IMAP::ResponseParseError: unexpected RPAR (expected ATOM or NIL)` - Confirmed `BODY[]` and `BODY.PEEK[]` fetch the same mailbox successfully - Confirmed Chatwoot imports iCloud messages after the IMAP change - Sent two outbound replies from the Chatwoot UI through iCloud SMTP after applying the timeout settings - Confirmed both UI-created outbound messages were marked `sent`, had iCloud SMTP `source_id` values, and had no `external_error` - Ran `bundle exec rspec spec/services/imap/fetch_email_service_spec.rb spec/services/imap/microsoft_fetch_email_service_spec.rb` - Ran `bundle exec rspec spec/mailers/conversation_reply_mailer_spec.rb` --- .../conversation_reply_mailer_helper.rb | 11 ++++++++-- app/services/imap/base_fetch_email_service.rb | 5 +++-- .../mailers/conversation_reply_mailer_spec.rb | 20 +++++++++++++++++++ .../services/imap/fetch_email_service_spec.rb | 14 ++++++------- .../microsoft_fetch_email_service_spec.rb | 12 +++++------ 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/app/mailers/conversation_reply_mailer_helper.rb b/app/mailers/conversation_reply_mailer_helper.rb index dc3e0c3fd..88266c14d 100644 --- a/app/mailers/conversation_reply_mailer_helper.rb +++ b/app/mailers/conversation_reply_mailer_helper.rb @@ -54,8 +54,7 @@ module ConversationReplyMailerHelper tls: false, enable_starttls_auto: true, openssl_verify_mode: 'none', - open_timeout: 15, - read_timeout: 15, + **smtp_timeout_settings, authentication: 'xoauth2' } end @@ -72,6 +71,7 @@ module ConversationReplyMailerHelper tls: @channel.smtp_enable_ssl_tls, enable_starttls_auto: @channel.smtp_enable_starttls_auto, openssl_verify_mode: @channel.smtp_openssl_verify_mode, + **smtp_timeout_settings, authentication: @channel.smtp_authentication } @@ -79,6 +79,13 @@ module ConversationReplyMailerHelper @options[:delivery_method_options] = smtp_settings end + def smtp_timeout_settings + { + open_timeout: ENV['SMTP_OPEN_TIMEOUT'].presence || 15, + read_timeout: ENV['SMTP_READ_TIMEOUT'].presence || 30 + }.transform_values(&:to_i) + end + def email_smtp_enabled? @inbox.inbox_type == 'Email' && @channel.smtp_enabled end diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb index 5a6d3537c..24dd22589 100644 --- a/app/services/imap/base_fetch_email_service.rb +++ b/app/services/imap/base_fetch_email_service.rb @@ -58,8 +58,9 @@ class Imap::BaseFetchEmailService return if email_already_present?(channel, message_id) - # Fetch the original mail content using the sequence no - mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822'] + # Fetch the original mail content using the sequence no. + # BODY.PEEK[] avoids RFC822 parser failures seen with some IMAP servers. + mail_str = imap_client.fetch(seq_no, 'BODY.PEEK[]')[0].attr['BODY[]'] if mail_str.blank? Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>." diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb index 86a2363e9..df4c4f41f 100644 --- a/spec/mailers/conversation_reply_mailer_spec.rb +++ b/spec/mailers/conversation_reply_mailer_spec.rb @@ -462,6 +462,26 @@ RSpec.describe ConversationReplyMailer do expect(mail.delivery_method.settings.empty?).to be false expect(mail.delivery_method.settings[:address]).to eq 'smtp.gmail.com' expect(mail.delivery_method.settings[:port]).to eq 587 + expect(mail.delivery_method.settings[:open_timeout]).to eq 15 + expect(mail.delivery_method.settings[:read_timeout]).to eq 30 + end + + it 'uses configured smtp timeout values' do + with_modified_env SMTP_OPEN_TIMEOUT: '10', SMTP_READ_TIMEOUT: '30' do + mail = described_class.email_reply(message) + + expect(mail.delivery_method.settings[:open_timeout]).to eq 10 + expect(mail.delivery_method.settings[:read_timeout]).to eq 30 + end + end + + it 'uses default smtp timeout values when env values are blank' do + with_modified_env SMTP_OPEN_TIMEOUT: '', SMTP_READ_TIMEOUT: '' do + mail = described_class.email_reply(message) + + expect(mail.delivery_method.settings[:open_timeout]).to eq 15 + expect(mail.delivery_method.settings[:read_timeout]).to eq 30 + end end it 'renders sender name in the from address' do diff --git a/spec/services/imap/fetch_email_service_spec.rb b/spec/services/imap/fetch_email_service_spec.rb index a40a46343..1f74cf0b7 100644 --- a/spec/services/imap/fetch_email_service_spec.rb +++ b/spec/services/imap/fetch_email_service_spec.rb @@ -80,11 +80,11 @@ RSpec.describe Imap::FetchEmailService do travel_to '26.10.2020 10:00'.to_datetime do email_object = create_inbound_email_from_fixture('only_text.eml') email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id) - imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id) + imap_fetch_mail = Net::IMAP::FetchData.new(1, 'BODY[]' => eml_content_with_message_id) allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return([1]) allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header]) - allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail]) + allow(imap).to receive(:fetch).with(1, 'BODY.PEEK[]').and_return([imap_fetch_mail]) allow(imap).to receive(:logout) result = described_class.new(channel: imap_email_channel).perform @@ -93,7 +93,7 @@ RSpec.describe Imap::FetchEmailService do expect(result[0].message_id).to eq email_object.message_id expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020]) expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]') - expect(imap).to have_received(:fetch).with(1, 'RFC822') + expect(imap).to have_received(:fetch).with(1, 'BODY.PEEK[]') expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{imap_email_channel.email}, found 1.") expect(imap).to have_received(:logout) end @@ -115,7 +115,7 @@ RSpec.describe Imap::FetchEmailService do expect(result.length).to eq 0 expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020]) expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]') - expect(imap).not_to have_received(:fetch).with(1, 'RFC822') + expect(imap).not_to have_received(:fetch).with(1, 'BODY.PEEK[]') end end @@ -129,12 +129,12 @@ RSpec.describe Imap::FetchEmailService do Net::IMAP::FetchData.new(seq_num, 'BODY[HEADER]' => eml_content_without_message_id) end valid_email_header = Net::IMAP::FetchData.new(valid_message_seq_num, 'BODY[HEADER]' => eml_content_with_message_id) - imap_fetch_mail = Net::IMAP::FetchData.new(valid_message_seq_num, 'RFC822' => eml_content_with_message_id) + imap_fetch_mail = Net::IMAP::FetchData.new(valid_message_seq_num, 'BODY[]' => eml_content_with_message_id) allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return(empty_message_id_seq_nums + [valid_message_seq_num]) allow(imap).to receive(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]').and_return(empty_message_id_headers) allow(imap).to receive(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]').and_return([valid_email_header]) - allow(imap).to receive(:fetch).with(valid_message_seq_num, 'RFC822').and_return([imap_fetch_mail]) + allow(imap).to receive(:fetch).with(valid_message_seq_num, 'BODY.PEEK[]').and_return([imap_fetch_mail]) allow(imap).to receive(:logout) result = described_class.new(channel: imap_email_channel).perform @@ -143,7 +143,7 @@ RSpec.describe Imap::FetchEmailService do expect(result[0].message_id).to eq email_object.message_id expect(imap).to have_received(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]') expect(imap).to have_received(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]') - expect(imap).to have_received(:fetch).with(valid_message_seq_num, 'RFC822') + expect(imap).to have_received(:fetch).with(valid_message_seq_num, 'BODY.PEEK[]') end end end diff --git a/spec/services/imap/microsoft_fetch_email_service_spec.rb b/spec/services/imap/microsoft_fetch_email_service_spec.rb index a4a0a62d1..cc20d5b35 100644 --- a/spec/services/imap/microsoft_fetch_email_service_spec.rb +++ b/spec/services/imap/microsoft_fetch_email_service_spec.rb @@ -30,11 +30,11 @@ RSpec.describe Imap::MicrosoftFetchEmailService do travel_to '26.10.2020 10:00'.to_datetime do email_object = create_inbound_email_from_fixture('only_text.eml') email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id) - imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id) + imap_fetch_mail = Net::IMAP::FetchData.new(1, 'BODY[]' => eml_content_with_message_id) allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return([1]) allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header]) - allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail]) + allow(imap).to receive(:fetch).with(1, 'BODY.PEEK[]').and_return([imap_fetch_mail]) allow(imap).to receive(:logout) result = described_class.new(channel: microsoft_channel).perform @@ -45,7 +45,7 @@ RSpec.describe Imap::MicrosoftFetchEmailService do expect(result[0].message_id).to eq email_object.message_id expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020]) expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]') - expect(imap).to have_received(:fetch).with(1, 'RFC822') + expect(imap).to have_received(:fetch).with(1, 'BODY.PEEK[]') expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{microsoft_channel.email}, found 1.") end end @@ -56,11 +56,11 @@ RSpec.describe Imap::MicrosoftFetchEmailService do travel_to '26.10.2020 10:00'.to_datetime do email_object = create_inbound_email_from_fixture('only_text.eml') email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id) - imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id) + imap_fetch_mail = Net::IMAP::FetchData.new(1, 'BODY[]' => eml_content_with_message_id) allow(imap).to receive(:search).with(%w[SINCE 18-Oct-2020]).and_return([1]) allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header]) - allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail]) + allow(imap).to receive(:fetch).with(1, 'BODY.PEEK[]').and_return([imap_fetch_mail]) allow(imap).to receive(:logout) result = described_class.new(channel: microsoft_channel, interval: 8).perform @@ -71,7 +71,7 @@ RSpec.describe Imap::MicrosoftFetchEmailService do expect(result[0].message_id).to eq email_object.message_id expect(imap).to have_received(:search).with(%w[SINCE 18-Oct-2020]) expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]') - expect(imap).to have_received(:fetch).with(1, 'RFC822') + expect(imap).to have_received(:fetch).with(1, 'BODY.PEEK[]') expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{microsoft_channel.email}, found 1.") end end From de893910315af0c891c18c2b8859af20c4ec8866 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:59:12 +0530 Subject: [PATCH 06/23] fix: use hang-up handset icon for reject/end call button (#14640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Linear Ticket https://linear.app/chatwoot/issue/CW-7261/call-window-theme-alignment ## Description Updates the call window's reject/end button to use the hang-up handset icon (the same handset as the accept button, rotated) instead of the phone-with-X, matching the design. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## Screenshots Screenshot 2026-06-03 at 3 41 10 PM ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings --- app/javascript/dashboard/components-next/call/CallCard.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/components-next/call/CallCard.vue b/app/javascript/dashboard/components-next/call/CallCard.vue index 207129282..55da3dacf 100644 --- a/app/javascript/dashboard/components-next/call/CallCard.vue +++ b/app/javascript/dashboard/components-next/call/CallCard.vue @@ -197,9 +197,9 @@ const channelIcon = computed(() => { ? $t('CONVERSATION.VOICE_WIDGET.END_CALL') : $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL') " - icon="i-ph-phone-x-bold" + icon="i-ph-phone-bold" ruby - class="!rounded-full rotate-[134deg]" + class="!rounded-full rotate-[135deg]" @click="isOngoing ? $emit('end') : $emit('reject')" />
From 0e87519ecdf7f9e22d21697308ddce5b5e5e1132 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:59:23 +0530 Subject: [PATCH 07/23] feat: add mute button for Twilio calls (#14637) ## Linear Ticket https://linear.app/chatwoot/issue/CW-7264/add-mute-button-in-twilio-calls ## Description Adds mute support for Twilio voice calls. Previously the mute button was shown only for WhatsApp calls (which toggle the local mic track in the browser), so Twilio calls had no way to mute. The call widget now routes mute by provider: WhatsApp continues to toggle the local mic track, while Twilio uses the Voice SDK connection's native `mute()`. The mute button is now shown for any active call, and unmute works symmetrically. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../api/channel/voice/twilioVoiceClient.js | 6 ++++++ .../components-next/call/FloatingCallWidget.vue | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js index 13f61a16c..14dd56ec9 100644 --- a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js +++ b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js @@ -48,6 +48,12 @@ class TwilioVoiceClient extends EventTarget { return !!this.activeConnection; } + setMuted(shouldMute) { + if (!this.activeConnection) return false; + this.activeConnection.mute(shouldMute); + return shouldMute; + } + endClientCall() { if (this.activeConnection) { this.activeConnection.disconnect(); diff --git a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue index 86d9e979c..f8dfabb35 100644 --- a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue @@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'; import { useStore } from 'vuex'; import { useCallSession } from 'dashboard/composables/useCallSession'; import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession'; +import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox'; import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants'; @@ -29,8 +30,8 @@ const { formattedCallDuration, } = useCallSession(); -// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and -// don't expose a mic track on the browser side. +// Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses +// the Voice SDK connection's native mute. Both surface the same button. const isMuted = ref(false); const isWhatsappActive = computed( () => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP @@ -63,7 +64,11 @@ const stackedCardState = call => const toggleMute = () => { isMuted.value = !isMuted.value; - setWhatsappCallMuted(isMuted.value); + if (isWhatsappActive.value) { + setWhatsappCallMuted(isMuted.value); + } else { + TwilioVoiceClient.setMuted(isMuted.value); + } }; watch(hasActiveCall, active => { @@ -256,7 +261,7 @@ onBeforeUnmount(stopRingtone); :call-info="getCallInfo(activeCall || primaryIncomingCall)" :duration="hasActiveCall ? formattedCallDuration : ''" :is-muted="isMuted" - :show-mute="hasActiveCall && isWhatsappActive" + :show-mute="hasActiveCall" @accept="handleJoinCall(primaryIncomingCall)" @reject="rejectIncomingCall(primaryIncomingCall?.callSid)" @dismiss="dismissCall(primaryIncomingCall?.callSid)" From 94ddd98050f2a0b4b4c6ed96a7494e96f9ee28c7 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:00:04 +0530 Subject: [PATCH 08/23] chore: update the voice call ringtone (#14636) ## Linear Ticket https://linear.app/chatwoot/issue/CW-7260/update-the-voice-call-ringtone ## Description Updates the incoming voice call ringtone. The dashboard call widget now plays the new tone while an inbound call is unanswered, replacing the previous bell sound. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --- .../components-next/call/FloatingCallWidget.vue | 2 +- public/audio/dashboard/ringtone.mp3 | Bin 0 -> 73197 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 public/audio/dashboard/ringtone.mp3 diff --git a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue index f8dfabb35..7758500a0 100644 --- a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue @@ -12,7 +12,7 @@ import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilit import CallCard from 'dashboard/components-next/call/CallCard.vue'; import countriesList from 'shared/constants/countries.js'; -const RINGTONE_URL = '/audio/dashboard/bell.mp3'; +const RINGTONE_URL = '/audio/dashboard/ringtone.mp3'; const route = useRoute(); const router = useRouter(); diff --git a/public/audio/dashboard/ringtone.mp3 b/public/audio/dashboard/ringtone.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c2af2b6d1d5c828c680fa90bf5c227c71402f34d GIT binary patch literal 73197 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 Date: Wed, 3 Jun 2026 16:01:14 +0530 Subject: [PATCH 09/23] feat: add Voice Calls feature card to settings showcase (#14635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Linear Ticket https://linear.app/chatwoot/issue/CW-7250/add-voice-calls-to-self-hosted-super-admin-feature-list ## Description Adds a **Voice Calls** card to the Super Admin → Settings → Features showcase so self-hostedadmins can see at a glance whether voice calling is available on the installation. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## Screenshots? Screenshot 2026-06-03 at 3 15 22 PM ## 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/helpers/super_admin/features.yml | 6 ++++++ app/views/super_admin/application/_icons.html.erb | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index f21a97f78..6489d0194 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -34,6 +34,12 @@ disable_branding: enabled: <%= (ChatwootHub.pricing_plan != 'community') %> icon: 'icon-sailbot-fill' enterprise: true +voice_calls: + name: 'Voice Calls' + description: 'Enable voice calling capabilities for your agents and customers.' + enabled: <%= (ChatwootHub.pricing_plan != 'community') %> + icon: 'icon-voice-line' + enterprise: true # ------- Product Features ------- # help_center: diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb index e80d1e164..39253c969 100644 --- a/app/views/super_admin/application/_icons.html.erb +++ b/app/views/super_admin/application/_icons.html.erb @@ -128,6 +128,10 @@ + + + + From 18ef019cd46b85d1003ae943d9f497c27d82f12c Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:11:32 +0530 Subject: [PATCH 10/23] fix: improve article editor typography & nested lists (#14572) --- .../dashboard/components/widgets/WootWriter/Editor.vue | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index a8f2d0a2c..09dc23819 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -979,10 +979,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); @apply text-n-slate-11; } } - - ol li { - @apply list-item list-decimal; - } } } From eaffad12e7b5dfc47f629f2a32b4420dc03cbdd3 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:45:19 +0530 Subject: [PATCH 11/23] feat(langfuse): propagate observation metadata for evals (#14634) # Pull Request Template ## Description We need to pass on trace level attributes down to the spans inside them like tool calls, observations, etc. This way, we can filter observations based on trace level attributes. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Attributes added to observation metadata for easy filtering image added a `generation_stage` to differentiate llm_calls that call tools vs those that generate a `final_response` CleanShot 2026-06-03 at 15 11 09@2x propagated attributes to tool calls for future use 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 - [x] Any dependent changes have been merged and published in downstream modules --- .../captain/chat_generation_recorder.rb | 15 +++- lib/captain/tool_instrumentation.rb | 19 ++--- lib/integrations/llm_instrumentation.rb | 38 +++------- .../llm_instrumentation_completion_helpers.rb | 8 -- .../llm_instrumentation_constants.rb | 1 + .../llm_instrumentation_context.rb | 41 ++++++++++ .../llm_instrumentation_helpers.rb | 53 +++++++++++-- lib/integrations/llm_instrumentation_spans.rb | 19 +++++ lib/opentelemetry_config.rb | 5 +- .../llm/assistant_chat_service_spec.rb | 24 ++++++ .../integrations/llm_instrumentation_spec.rb | 75 ++++++++++++++++++- 11 files changed, 246 insertions(+), 52 deletions(-) create mode 100644 lib/integrations/llm_instrumentation_context.rb diff --git a/enterprise/app/helpers/captain/chat_generation_recorder.rb b/enterprise/app/helpers/captain/chat_generation_recorder.rb index cd631fb16..63bcdc276 100644 --- a/enterprise/app/helpers/captain/chat_generation_recorder.rb +++ b/enterprise/app/helpers/captain/chat_generation_recorder.rb @@ -10,6 +10,7 @@ module Captain::ChatGenerationRecorder # Create a generation span with model and token info for Langfuse cost calculation. # Note: span duration will be near-zero since we create and end it immediately, but token counts are what Langfuse uses for cost calculation. tracer.in_span("llm.captain.#{feature_name}.generation") do |span| + apply_current_langfuse_attributes(span) set_generation_span_attributes(span, chat, message) end rescue StandardError => e @@ -37,11 +38,23 @@ module Captain::ChatGenerationRecorder ATTR_GEN_AI_USAGE_INPUT_TOKENS => message.input_tokens, ATTR_GEN_AI_USAGE_OUTPUT_TOKENS => message.respond_to?(:output_tokens) ? message.output_tokens : nil, ATTR_LANGFUSE_OBSERVATION_INPUT => format_input_messages(chat), - ATTR_LANGFUSE_OBSERVATION_OUTPUT => message.respond_to?(:content) ? message.content.to_s : nil + ATTR_LANGFUSE_OBSERVATION_OUTPUT => message.respond_to?(:content) ? message.content.to_s : nil, + format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'generation_stage') => generation_stage(message) } end def format_input_messages(chat) chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json end + + def generation_stage(message) + message_has_tool_calls?(message) ? 'tool_call' : 'final_response' + end + + def message_has_tool_calls?(message) + return false unless message.respond_to?(:tool_calls) + + tool_calls = message.tool_calls + tool_calls.respond_to?(:any?) && tool_calls.any? + end end diff --git a/lib/captain/tool_instrumentation.rb b/lib/captain/tool_instrumentation.rb index 157aab829..3c4a1c76e 100644 --- a/lib/captain/tool_instrumentation.rb +++ b/lib/captain/tool_instrumentation.rb @@ -10,12 +10,14 @@ module Captain::ToolInstrumentation response = nil executed = false - tracer.in_span(params[:span_name]) do |span| - set_tool_session_attributes(span, params) - response = yield - executed = true - span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json) - set_tool_session_error_attributes(span, response) if response.is_a?(Hash) + with_propagated_langfuse_attributes(params) do + tracer.in_span(params[:span_name]) do |span| + set_tool_session_attributes(span, params) + response = yield + executed = true + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json) + set_tool_session_error_attributes(span, response) if response.is_a?(Hash) + end end response rescue StandardError => e @@ -24,9 +26,7 @@ module Captain::ToolInstrumentation end def set_tool_session_attributes(span, params) - span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id] - span.set_attribute(ATTR_LANGFUSE_SESSION_ID, "#{params[:account_id]}_#{params[:conversation_id]}") if params[:conversation_id].present? - span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) + set_metadata_attributes(span, params) span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) end @@ -43,6 +43,7 @@ module Captain::ToolInstrumentation return unless message.respond_to?(:role) && message.role.to_s == 'assistant' tracer.in_span("llm.#{event_name}.generation") do |span| + apply_current_langfuse_attributes(span) span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai') span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model) span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens) diff --git a/lib/integrations/llm_instrumentation.rb b/lib/integrations/llm_instrumentation.rb index 326bb901e..0257f5c3a 100644 --- a/lib/integrations/llm_instrumentation.rb +++ b/lib/integrations/llm_instrumentation.rb @@ -29,16 +29,18 @@ module Integrations::LlmInstrumentation result = nil executed = false - tracer.in_span(params[:span_name]) do |span| - set_metadata_attributes(span, params) + with_propagated_langfuse_attributes(params) do + tracer.in_span(params[:span_name]) do |span| + set_metadata_attributes(span, params) - # By default, the input and output of a trace are set from the root observation - span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) - result = yield - executed = true - span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json) - set_error_attributes(span, result) if result.is_a?(Hash) - result + # By default, the input and output of a trace are set from the root observation + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) + result = yield + executed = true + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json) + set_error_attributes(span, result) if result.is_a?(Hash) + result + end end rescue StandardError => e ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception @@ -51,6 +53,7 @@ module Integrations::LlmInstrumentation return yield unless ChatwootApp.otel_enabled? tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span| + apply_current_langfuse_attributes(span) span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool') span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json) result = yield @@ -96,23 +99,6 @@ module Integrations::LlmInstrumentation end end - def instrument_with_span(span_name, params, &) - result = nil - executed = false - tracer.in_span(span_name) do |span| - track_result = lambda do |r| - executed = true - result = r - end - yield(span, track_result) - end - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception - raise unless executed - - result - end - private def resolve_account(params) diff --git a/lib/integrations/llm_instrumentation_completion_helpers.rb b/lib/integrations/llm_instrumentation_completion_helpers.rb index 551d0780f..26af2aae1 100644 --- a/lib/integrations/llm_instrumentation_completion_helpers.rb +++ b/lib/integrations/llm_instrumentation_completion_helpers.rb @@ -10,7 +10,6 @@ module Integrations::LlmInstrumentationCompletionHelpers span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model]) span.set_attribute('embedding.input_length', params[:input]&.length || 0) span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s) - set_common_span_metadata(span, params) end def set_audio_transcription_span_attributes(span, params) @@ -18,7 +17,6 @@ module Integrations::LlmInstrumentationCompletionHelpers span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'whisper-1') span.set_attribute('audio.duration_seconds', params[:duration]) if params[:duration] span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:file_path].to_s) if params[:file_path] - set_common_span_metadata(span, params) end def set_moderation_span_attributes(span, params) @@ -26,12 +24,6 @@ module Integrations::LlmInstrumentationCompletionHelpers span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'text-moderation-latest') span.set_attribute('moderation.input_length', params[:input]&.length || 0) span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s) - set_common_span_metadata(span, params) - end - - def set_common_span_metadata(span, params) - span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id] - span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) if params[:feature_name] end def set_embedding_result_attributes(span, result) diff --git a/lib/integrations/llm_instrumentation_constants.rb b/lib/integrations/llm_instrumentation_constants.rb index dfe1e7704..f274d1145 100644 --- a/lib/integrations/llm_instrumentation_constants.rb +++ b/lib/integrations/llm_instrumentation_constants.rb @@ -29,4 +29,5 @@ module Integrations::LlmInstrumentationConstants ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type' ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input' ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output' + ATTR_LANGFUSE_OBSERVATION_METADATA = 'langfuse.observation.metadata.%s' end diff --git a/lib/integrations/llm_instrumentation_context.rb b/lib/integrations/llm_instrumentation_context.rb new file mode 100644 index 000000000..27b1eb2b2 --- /dev/null +++ b/lib/integrations/llm_instrumentation_context.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Integrations::LlmInstrumentationContext + LANGFUSE_ATTRIBUTES_KEY = :llm_instrumentation_langfuse_attributes + LANGFUSE_OBSERVATION_METADATA_KEY = :llm_instrumentation_langfuse_observation_metadata_attributes + + private + + def with_propagated_langfuse_attributes(params) + previous_attributes = current_langfuse_attributes + previous_observation_metadata_attributes = current_observation_metadata_attributes + self.current_langfuse_attributes = previous_attributes.merge(propagated_langfuse_attributes(params)) + self.current_observation_metadata_attributes = previous_observation_metadata_attributes.merge(propagated_observation_metadata_attributes(params)) + + yield + ensure + self.current_langfuse_attributes = previous_attributes + self.current_observation_metadata_attributes = previous_observation_metadata_attributes + end + + def apply_current_langfuse_attributes(span) + set_langfuse_attributes(span, current_langfuse_attributes) + set_langfuse_attributes(span, current_observation_metadata_attributes) + end + + def current_langfuse_attributes + ActiveSupport::IsolatedExecutionState[LANGFUSE_ATTRIBUTES_KEY] || {} + end + + def current_langfuse_attributes=(attrs) + ActiveSupport::IsolatedExecutionState[LANGFUSE_ATTRIBUTES_KEY] = attrs + end + + def current_observation_metadata_attributes + ActiveSupport::IsolatedExecutionState[LANGFUSE_OBSERVATION_METADATA_KEY] || {} + end + + def current_observation_metadata_attributes=(attrs) + ActiveSupport::IsolatedExecutionState[LANGFUSE_OBSERVATION_METADATA_KEY] = attrs + end +end diff --git a/lib/integrations/llm_instrumentation_helpers.rb b/lib/integrations/llm_instrumentation_helpers.rb index 129092ed4..debbfaeda 100644 --- a/lib/integrations/llm_instrumentation_helpers.rb +++ b/lib/integrations/llm_instrumentation_helpers.rb @@ -2,6 +2,7 @@ module Integrations::LlmInstrumentationHelpers include Integrations::LlmInstrumentationConstants + include Integrations::LlmInstrumentationContext include Integrations::LlmInstrumentationCompletionHelpers def determine_provider(model_name) @@ -51,15 +52,55 @@ module Integrations::LlmInstrumentationHelpers end def set_metadata_attributes(span, params) - session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil - span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id] - span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present? - span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) + set_langfuse_attributes(span, current_langfuse_attributes.merge(propagated_langfuse_attributes(params))) + set_langfuse_attributes(span, current_observation_metadata_attributes.merge(propagated_observation_metadata_attributes(params))) + end - return unless params[:metadata].is_a?(Hash) + def propagated_langfuse_attributes(params) + attrs = {} + session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil + + attrs[ATTR_LANGFUSE_USER_ID] = params[:account_id].to_s if params[:account_id] + attrs[ATTR_LANGFUSE_SESSION_ID] = session_id if session_id.present? + attrs[ATTR_LANGFUSE_TAGS] = [params[:feature_name].to_s] if params[:feature_name].present? + + return attrs unless params[:metadata].is_a?(Hash) params[:metadata].each do |key, value| - span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s) + attrs[format(ATTR_LANGFUSE_METADATA, key)] = value.to_s + end + + attrs + end + + def propagated_observation_metadata_attributes(params) + attrs = {} + session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil + + add_observation_metadata(attrs, 'user_id', params[:account_id]) + add_observation_metadata(attrs, 'account_id', params[:account_id]) + add_observation_metadata(attrs, 'session_id', session_id) + add_observation_metadata(attrs, 'trace_tags', [params[:feature_name]].to_json) + add_observation_metadata(attrs, 'feature_name', params[:feature_name]) + + return attrs unless params[:metadata].is_a?(Hash) + + params[:metadata].each do |key, value| + add_observation_metadata(attrs, key, value) + end + + attrs + end + + def add_observation_metadata(attrs, key, value) + return if value.blank? + + attrs[format(ATTR_LANGFUSE_OBSERVATION_METADATA, key)] = value.to_s + end + + def set_langfuse_attributes(span, attrs) + attrs.each do |key, value| + span.set_attribute(key, value) end end end diff --git a/lib/integrations/llm_instrumentation_spans.rb b/lib/integrations/llm_instrumentation_spans.rb index 85ea599f8..2def9749d 100644 --- a/lib/integrations/llm_instrumentation_spans.rb +++ b/lib/integrations/llm_instrumentation_spans.rb @@ -39,6 +39,7 @@ module Integrations::LlmInstrumentationSpans tool_name = tool_call.name.to_s span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name)) + apply_current_langfuse_attributes(span) span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool') span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json) @@ -61,6 +62,24 @@ module Integrations::LlmInstrumentationSpans Rails.logger.warn "Failed to end tool span: #{e.message}" end + def instrument_with_span(span_name, params, &) + result = nil + executed = false + tracer.in_span(span_name) do |span| + set_metadata_attributes(span, params) + track_result = lambda do |r| + executed = true + result = r + end + yield(span, track_result) + end + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception + raise unless executed + + result + end + private def set_llm_turn_request_attributes(span, params) diff --git a/lib/opentelemetry_config.rb b/lib/opentelemetry_config.rb index 5ed17e098..32be413d0 100644 --- a/lib/opentelemetry_config.rb +++ b/lib/opentelemetry_config.rb @@ -72,7 +72,10 @@ module OpentelemetryConfig config = { endpoint: traces_endpoint, - headers: { 'Authorization' => "Basic #{auth_header}" } + headers: { + 'Authorization' => "Basic #{auth_header}", + 'x-langfuse-ingestion-version' => '4' + } } config[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE if Rails.env.development? diff --git a/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb b/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb index 9d233943e..6b2cc55c8 100644 --- a/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb +++ b/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb @@ -39,6 +39,30 @@ RSpec.describe Captain::Llm::AssistantChatService do allow(mock_chat).to receive(:ask).and_return(mock_response) service.generate_response(message_history: [{ role: 'user', content: 'Hello' }]) end + + it 'marks final response generations for observation-level evaluators' do + service = described_class.new(assistant: assistant, conversation: conversation) + message = instance_double(RubyLLM::Message, content: 'Final answer', input_tokens: 10, output_tokens: 20, tool_calls: {}) + + attributes = service.send(:generation_attributes, mock_chat, message) + + expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('final_response') + end + + it 'marks tool call generations separately from final responses' do + service = described_class.new(assistant: assistant, conversation: conversation) + message = instance_double( + RubyLLM::Message, + content: '', + input_tokens: 10, + output_tokens: 20, + tool_calls: { 'call_1' => instance_double(RubyLLM::ToolCall) } + ) + + attributes = service.send(:generation_attributes, mock_chat, message) + + expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('tool_call') + end end describe 'image analysis' do diff --git a/spec/lib/integrations/llm_instrumentation_spec.rb b/spec/lib/integrations/llm_instrumentation_spec.rb index 0be62f437..f291cd7b2 100644 --- a/spec/lib/integrations/llm_instrumentation_spec.rb +++ b/spec/lib/integrations/llm_instrumentation_spec.rb @@ -144,7 +144,10 @@ RSpec.describe Integrations::LlmInstrumentation do expect(mock_span).to have_received(:set_attribute).with('langfuse.user.id', '123') expect(mock_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456') - expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', '["reply_suggestion"]') + expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['reply_suggestion']) + expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.user_id', '123') + expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456') + expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'reply_suggestion') end it 'sets completion message attributes when result contains message' do @@ -253,6 +256,76 @@ RSpec.describe Integrations::LlmInstrumentation do expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', result_data.to_json) end + it 'propagates trace attributes as observation metadata to child tool spans' do + root_span = instance_double(OpenTelemetry::Trace::Span) + tool_span = instance_double(OpenTelemetry::Trace::Span) + tool_instance = test_class.new + allow(root_span).to receive(:set_attribute) + allow(tool_span).to receive(:set_attribute) + allow(instance).to receive(:tracer).and_return(mock_tracer) + allow(tool_instance).to receive(:tracer).and_return(mock_tracer) + allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span) + allow(mock_tracer).to receive(:in_span).with('tool.search').and_yield(tool_span) + + instance.instrument_agent_session(params) do + tool_instance.instrument_tool_call('search', { query: 'test' }) { 'tool result' } + end + + expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.user_id', '123') + expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456') + expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'reply_suggestion') + end + + it 'keeps inherited session metadata for nested service spans with their own feature tag' do + root_span = instance_double(OpenTelemetry::Trace::Span) + nested_span = instance_double(OpenTelemetry::Trace::Span) + nested_instance = test_class.new + nested_params = params.merge(span_name: 'llm.translate_query', conversation_id: nil, feature_name: 'translate_query') + allow(root_span).to receive(:set_attribute) + allow(nested_span).to receive(:set_attribute) + allow(instance).to receive(:tracer).and_return(mock_tracer) + allow(nested_instance).to receive(:tracer).and_return(mock_tracer) + allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span) + allow(mock_tracer).to receive(:in_span).with('llm.translate_query').and_yield(nested_span) + + instance.instrument_agent_session(params) do + nested_instance.instrument_llm_call(nested_params) { 'translated query' } + end + + expect(nested_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456') + expect(nested_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['translate_query']) + expect(nested_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456') + expect(nested_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'translate_query') + end + + it 'propagates session metadata to nested embedding spans' do + root_span = instance_double(OpenTelemetry::Trace::Span) + embedding_span = instance_double(OpenTelemetry::Trace::Span) + embedding_instance = test_class.new + embedding_params = { + span_name: 'llm.captain.embedding', + account_id: 123, + feature_name: 'embedding', + model: 'text-embedding-3-small', + input: 'search result' + } + allow(root_span).to receive(:set_attribute) + allow(embedding_span).to receive(:set_attribute) + allow(instance).to receive(:tracer).and_return(mock_tracer) + allow(embedding_instance).to receive(:tracer).and_return(mock_tracer) + allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span) + allow(mock_tracer).to receive(:in_span).with('llm.captain.embedding').and_yield(embedding_span) + + instance.instrument_agent_session(params) do + embedding_instance.instrument_embedding_call(embedding_params) { [0.1, 0.2, 0.3] } + end + + expect(embedding_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456') + expect(embedding_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['embedding']) + expect(embedding_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456') + expect(embedding_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'embedding') + end + # Regression test for Langfuse double-counting bug. # Setting gen_ai.request.model on parent spans causes Langfuse to classify them as # GENERATIONs instead of SPANs, resulting in cost being counted multiple times From cd9192f7d129a9629ae83908afd119321ea9b8a5 Mon Sep 17 00:00:00 2001 From: tomsideguide Date: Wed, 3 Jun 2026 14:17:49 -0400 Subject: [PATCH 12/23] chore(captain): update Firecrawl to use the v2 API (#14624) ## Description Migrates Firecrawl from the v1 to the v2 API. `Captain::Tools::FirecrawlService` now targets `api.firecrawl.dev/v2`, with the request body updated to match the v2 schema. > Disclosure: I work at Firecrawl. Fixes # (n/a) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Updated `spec/enterprise/services/captain/tools/firecrawl_service_spec.rb` to assert the v2 endpoint and request body. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes --------- Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> --- .../captain/tools/firecrawl_service.rb | 11 +++++---- .../captain/tools/firecrawl_service_spec.rb | 23 ++++++++++--------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/enterprise/app/services/captain/tools/firecrawl_service.rb b/enterprise/app/services/captain/tools/firecrawl_service.rb index bee7219e8..6797634a2 100644 --- a/enterprise/app/services/captain/tools/firecrawl_service.rb +++ b/enterprise/app/services/captain/tools/firecrawl_service.rb @@ -1,5 +1,5 @@ class Captain::Tools::FirecrawlService - BASE_URL = 'https://api.firecrawl.dev/v1'.freeze + BASE_URL = 'https://api.firecrawl.dev/v2'.freeze FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze def self.configured? @@ -35,10 +35,10 @@ class Captain::Tools::FirecrawlService def crawl_payload(url, webhook_url, crawl_limit) { url: url, - maxDepth: 50, - ignoreSitemap: false, + maxDiscoveryDepth: 50, + sitemap: 'include', limit: crawl_limit, - webhook: webhook_url, + webhook: { url: webhook_url }, scrapeOptions: scrape_options }.to_json end @@ -51,7 +51,8 @@ class Captain::Tools::FirecrawlService { onlyMainContent: true, formats: ['markdown'], - excludeTags: FIRECRAWL_EXCLUDE_TAGS + excludeTags: FIRECRAWL_EXCLUDE_TAGS, + maxAge: 0 } end diff --git a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb index 4d4bc7aaf..9a099fc67 100644 --- a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb +++ b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb @@ -53,14 +53,15 @@ RSpec.describe Captain::Tools::FirecrawlService do let(:expected_payload) do { url: url, - maxDepth: 50, - ignoreSitemap: false, + maxDiscoveryDepth: 50, + sitemap: 'include', limit: crawl_limit, - webhook: webhook_url, + webhook: { url: webhook_url }, scrapeOptions: { onlyMainContent: true, formats: ['markdown'], - excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS + excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS, + maxAge: 0 } }.to_json end @@ -74,7 +75,7 @@ RSpec.describe Captain::Tools::FirecrawlService do context 'when the API call is successful' do before do - stub_request(:post, 'https://api.firecrawl.dev/v1/crawl') + stub_request(:post, 'https://api.firecrawl.dev/v2/crawl') .with( body: expected_payload, headers: expected_headers @@ -85,7 +86,7 @@ RSpec.describe Captain::Tools::FirecrawlService do it 'makes a POST request with correct parameters' do service.perform(url, webhook_url, crawl_limit) - expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl') + expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl') .with( body: expected_payload, headers: expected_headers @@ -95,7 +96,7 @@ RSpec.describe Captain::Tools::FirecrawlService do it 'uses default crawl limit when not specified' do default_payload = expected_payload.gsub(crawl_limit.to_s, '10') - stub_request(:post, 'https://api.firecrawl.dev/v1/crawl') + stub_request(:post, 'https://api.firecrawl.dev/v2/crawl') .with( body: default_payload, headers: expected_headers @@ -104,7 +105,7 @@ RSpec.describe Captain::Tools::FirecrawlService do service.perform(url, webhook_url) - expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl') + expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl') .with( body: default_payload, headers: expected_headers @@ -114,7 +115,7 @@ RSpec.describe Captain::Tools::FirecrawlService do context 'when the API call fails' do before do - stub_request(:post, 'https://api.firecrawl.dev/v1/crawl') + stub_request(:post, 'https://api.firecrawl.dev/v2/crawl') .to_raise(StandardError.new('Connection failed')) end @@ -126,14 +127,14 @@ RSpec.describe Captain::Tools::FirecrawlService do context 'when the API returns an error response' do before do - stub_request(:post, 'https://api.firecrawl.dev/v1/crawl') + stub_request(:post, 'https://api.firecrawl.dev/v2/crawl') .to_return(status: 422, body: '{"error": "Invalid URL"}') end it 'makes the request but does not raise an error' do expect { service.perform(url, webhook_url, crawl_limit) }.not_to raise_error - expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl') + expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl') .with( body: expected_payload, headers: expected_headers From 64c5aeebeeab99f9a8fbd93de5f40d88f241e787 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 4 Jun 2026 06:34:12 -0700 Subject: [PATCH 13/23] feat(help-center): support per-locale portal title, name & header (#14642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portals can now override their name, page title and header text per locale, with a fallback chain of locale override → default locale → base value. Overrides are stored under portal.config.locale_translations and validated with a JSON schema. Editing is exposed as a "Localize content" action in each non-default locale's menu on the Locale page, and all public-facing portal views (classic + documentation layouts) render the localized values. The live chat widget is also loaded in the active portal locale. Screenshot 2026-06-03 at 2 48 59 PM https://github.com/user-attachments/assets/f5affbd2-7ad4-415a-b376-57c759fc2aaa --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../api/v1/accounts/portals_controller.rb | 7 +- .../public/api/v1/portals_controller.rb | 2 +- .../HelpCenter/LocaleCard/LocaleCard.vue | 5 +- .../Pages/LocalePage/LocaleContentDialog.vue | 98 +++++++++++++++++++ .../Pages/LocalePage/LocaleList.vue | 7 ++ .../dashboard/helper/portalHelper.js | 27 +++-- .../helper/specs/portalHelper.spec.js | 35 ++++--- .../dashboard/i18n/locale/en/helpCenter.json | 18 ++++ app/models/concerns/portal_config_schema.rb | 35 +++++++ app/models/portal.rb | 29 +++++- .../v1/accounts/portals/_portal.json.jbuilder | 1 + app/views/layouts/_portal_head.html.erb | 2 +- app/views/layouts/_portal_scripts.html.erb | 4 + .../public/api/v1/portals/_header.html.erb | 4 +- .../public/api/v1/portals/_hero.html.erb | 8 +- .../api/v1/portals/articles/index.html.erb | 2 +- .../api/v1/portals/articles/show.html.erb | 2 +- .../api/v1/portals/categories/_hero.html.erb | 2 +- .../api/v1/portals/categories/show.html.erb | 4 +- .../documentation_layout/_hero.html.erb | 2 +- .../documentation_layout/_topbar.html.erb | 2 +- .../articles/_meta_head.html.erb | 2 +- .../categories/_meta_head.html.erb | 4 +- .../search/index.html+documentation.erb | 2 +- .../api/v1/portals/search/index.html.erb | 2 +- .../v1/accounts/portals_controller_spec.rb | 3 +- spec/models/portal_spec.rb | 88 +++++++++++++++++ 27 files changed, 347 insertions(+), 50 deletions(-) create mode 100644 app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue create mode 100644 app/models/concerns/portal_config_schema.rb diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 770018e3c..c74c0ecfc 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -80,10 +80,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController :id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, - { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] } + { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }, + { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] } ) end + def locale_translation_keys + params.dig(:portal, :config, :locale_translations)&.keys || [] + end + def live_chat_widget_params permitted_params = params.permit(:inbox_id) return {} unless permitted_params.key?(:inbox_id) diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index 63f44b052..57db11aec 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl layout 'portal' def show - @og_image_url = helpers.set_og_image_url('', @portal.header_text) + @og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale)) end def sitemap diff --git a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue index 8619e5b77..259328bec 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue @@ -53,6 +53,9 @@ const localeMenuLabels = computed(() => ({ 'publish-locale': t( 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE' ), + 'customize-content': t( + 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT' + ), delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'), })); @@ -128,7 +131,7 @@ const handleAction = ({ action, value }) => { diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue new file mode 100644 index 000000000..11d38f2a2 --- /dev/null +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue @@ -0,0 +1,98 @@ + + + diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue index 62c644655..66d389ead 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue @@ -1,5 +1,7 @@ <% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %> + <%= @portal.channel_web_widget.web_widget_script.html_safe %> <% end %> diff --git a/app/views/public/api/v1/portals/_header.html.erb b/app/views/public/api/v1/portals/_header.html.erb index 3db8efa59..189371261 100644 --- a/app/views/public/api/v1/portals/_header.html.erb +++ b/app/views/public/api/v1/portals/_header.html.erb @@ -5,7 +5,7 @@ <% if @portal.logo.present? %> <% end %> - <%= @portal.name %> + <%= @portal.localized_value('name', @locale) %> @@ -106,7 +106,7 @@ <% if @portal.logo.present? %> <% end %> - <%= @portal.name %> + <%= @portal.localized_value('name', @locale) %> diff --git a/app/views/public/api/v1/portals/_hero.html.erb b/app/views/public/api/v1/portals/_hero.html.erb index c1ead0e59..1e2bf119c 100644 --- a/app/views/public/api/v1/portals/_hero.html.erb +++ b/app/views/public/api/v1/portals/_hero.html.erb @@ -1,7 +1,7 @@ <% if !@is_plain_layout_enabled %> <% content_for :head do %> - <%= @portal.display_title %> - + <%= @portal.display_title(@locale) %> + <% if @og_image_url.present? %> @@ -13,9 +13,9 @@
- <%= @portal.name %> + <%= @portal.localized_value('name', @locale) %>

- <%= portal.header_text %> + <%= portal.localized_value('header_text', @locale) %>

<%= I18n.t('public_portal.hero.sub_title') %>

diff --git a/app/views/public/api/v1/portals/articles/index.html.erb b/app/views/public/api/v1/portals/articles/index.html.erb index d040bbc24..e4dc9aa87 100644 --- a/app/views/public/api/v1/portals/articles/index.html.erb +++ b/app/views/public/api/v1/portals/articles/index.html.erb @@ -6,7 +6,7 @@ class="leading-8 text-slate-800 hover:underline" href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>" > - <%= @portal.name %> <%= I18n.t('public_portal.common.home') %> + <%= @portal.localized_value('name', @locale) %> <%= I18n.t('public_portal.common.home') %> / / diff --git a/app/views/public/api/v1/portals/articles/show.html.erb b/app/views/public/api/v1/portals/articles/show.html.erb index 784817c9c..e35b19009 100644 --- a/app/views/public/api/v1/portals/articles/show.html.erb +++ b/app/views/public/api/v1/portals/articles/show.html.erb @@ -1,5 +1,5 @@ <% content_for :head do %> - <%= @article.title %> | <%= @portal.display_title %> + <%= @article.title %> | <%= @portal.display_title(@locale) %> <% if @article.meta["title"].present? %> "> "> diff --git a/app/views/public/api/v1/portals/categories/_hero.html.erb b/app/views/public/api/v1/portals/categories/_hero.html.erb index 0c179338d..1b481617e 100644 --- a/app/views/public/api/v1/portals/categories/_hero.html.erb +++ b/app/views/public/api/v1/portals/categories/_hero.html.erb @@ -1,7 +1,7 @@
-

<%= portal.header_text %>

+

<%= portal.localized_value('header_text', @locale) %>

<%= I18n.t('public_portal.hero.sub_title') %>

diff --git a/app/views/public/api/v1/portals/categories/show.html.erb b/app/views/public/api/v1/portals/categories/show.html.erb index 6657559d0..e7179db73 100644 --- a/app/views/public/api/v1/portals/categories/show.html.erb +++ b/app/views/public/api/v1/portals/categories/show.html.erb @@ -1,6 +1,6 @@ <% content_for :head do %> - <%= @category.name %> | <%= @portal.display_title %> - + <%= @category.name %> | <%= @portal.display_title(@locale) %> + <% if @category.description.present? %> diff --git a/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb b/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb index c7df0517d..af80d46ac 100644 --- a/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb +++ b/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb @@ -5,7 +5,7 @@

- <%= portal.header_text.presence || 'How can we help?' %> + <%= portal.localized_value('header_text', @locale).presence || 'How can we help?' %>

<%= I18n.t('public_portal.hero.sub_title') %> diff --git a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb index 46d6f3558..00f408d70 100644 --- a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb +++ b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb @@ -11,7 +11,7 @@ <% if portal.logo.present? %> <% end %> - <%= portal.name %> + <%= portal.localized_value('name', locale) %> diff --git a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb index a236722ae..aeed7ff6c 100644 --- a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb +++ b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb @@ -1,4 +1,4 @@ -<%= article.title %> | <%= portal.display_title %> +<%= article.title %> | <%= portal.display_title(article.locale) %> <% if article.meta["title"].present? %> "> "> diff --git a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb index 7c8dec8d2..ac36496df 100644 --- a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb +++ b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb @@ -1,5 +1,5 @@ -<%= category.name %> | <%= portal.display_title %> - +<%= category.name %> | <%= portal.display_title(category.locale) %> + <% if category.description.present? %> diff --git a/app/views/public/api/v1/portals/search/index.html+documentation.erb b/app/views/public/api/v1/portals/search/index.html+documentation.erb index 8577a5f4e..f77517618 100644 --- a/app/views/public/api/v1/portals/search/index.html+documentation.erb +++ b/app/views/public/api/v1/portals/search/index.html+documentation.erb @@ -1,5 +1,5 @@ <% content_for :head do %> - <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %> + <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %> <% end %>

diff --git a/app/views/public/api/v1/portals/search/index.html.erb b/app/views/public/api/v1/portals/search/index.html.erb index 82c29775f..b4a1e77ed 100644 --- a/app/views/public/api/v1/portals/search/index.html.erb +++ b/app/views/public/api/v1/portals/search/index.html.erb @@ -1,5 +1,5 @@ <% content_for :head do %> - <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %> + <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %> <% end %> <% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %> diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb index 860791c0e..ccb5d7449 100644 --- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb @@ -173,7 +173,8 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do ], 'default_locale' => 'en', 'layout' => 'classic', - 'social_profiles' => {} + 'social_profiles' => {}, + 'locale_translations' => {} } ) end diff --git a/spec/models/portal_spec.rb b/spec/models/portal_spec.rb index 38d9a7da6..c71a458fd 100644 --- a/spec/models/portal_spec.rb +++ b/spec/models/portal_spec.rb @@ -60,6 +60,94 @@ RSpec.describe Portal do portal.update(custom_domain: '') expect(portal.custom_domain).to be_nil end + + context 'with locale_translations' do + it 'allows valid locale translations' do + portal.update(config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'name' => 'Centro', 'page_title' => 'Título', 'header_text' => 'Hola' } } }) + + expect(portal).to be_valid + end + + it 'rejects unknown fields within a locale translation' do + portal.update(config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'tagline' => 'nope' } } }) + + expect(portal).not_to be_valid + end + + it 'retains a locale override after it becomes the default so it can still be edited' do + portal.update!(config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'name' => 'Centro' } } }) + + portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' }) + + expect(portal.config['locale_translations']).to eq({ 'es' => { 'name' => 'Centro' } }) + end + end + end + end + + describe '#localized_value' do + let!(:account) { create(:account) } + let!(:portal) do + create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme', + config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } }) + end + + it 'returns the override for the requested locale' do + expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda') + end + + it 'falls back to the base column when the locale has no override for the field' do + expect(portal.localized_value('page_title', 'es')).to eq('Help Center | Acme') + end + + it 'falls back to the base column when the locale has no overrides at all' do + expect(portal.localized_value('name', 'fr')).to eq('Help Center') + end + + it 'keeps serving the override for a locale that has become the default' do + portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' }) + + expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda') + end + + it "inherits the default locale's override for a locale without its own" do + portal.update!(config: { allowed_locales: %w[en es fr], default_locale: 'es' }) + + expect(portal.localized_value('name', 'fr')).to eq('Centro de ayuda') + end + + it 'uses the default locale when no locale is given' do + expect(portal.localized_value('name')).to eq('Help Center') + end + end + + describe '#display_title' do + let!(:account) { create(:account) } + + it 'prefers the localized page_title' do + portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme', + config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'page_title' => 'Centro | Acme' } } }) + + expect(portal.display_title('es')).to eq('Centro | Acme') + end + + it 'falls back to the localized name when no page_title is set' do + portal = create(:portal, account_id: account.id, name: 'Help Center', + config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } }) + + expect(portal.display_title('es')).to eq('Centro de ayuda') + end + + it 'uses the base values for the default locale' do + portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme') + + expect(portal.display_title).to eq('Help Center | Acme') end end end From ec43975f3f6ec64fb63b752acba571e740d57693 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:25:32 +0530 Subject: [PATCH 14/23] chore: use square avatars (#14656) --- .../components-next/Companies/CompaniesCard/CompaniesCard.vue | 3 +-- .../Companies/CompanyDetail/CompanyProfileCard.vue | 1 - .../components-next/Contacts/ContactsCard/ContactsCard.vue | 3 +-- .../dashboard/components-next/sidebar/SidebarProfileMenu.vue | 1 - .../components/widgets/conversation/ConversationHeader.vue | 1 - .../routes/dashboard/conversation/contact/ContactInfo.vue | 1 - 6 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue index fe4385bbb..d81b997c5 100644 --- a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue +++ b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue @@ -46,9 +46,8 @@ const formattedLastActivityAt = computed(() => { :src="avatarSource" class="shrink-0" :name="name" - :size="48" + :size="42" hide-offline-status - rounded-full />
diff --git a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue index 1ffbf0c18..7615328ca 100644 --- a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue +++ b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue @@ -141,7 +141,6 @@ const handleUpdateCompany = async () => { :src="avatarSource" :size="72" :allow-upload="!isAvatarBusy" - rounded-full hide-offline-status @upload="handleAvatarUpload" @delete="handleAvatarDelete" diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index ba887b46f..50af6f0c0 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -124,10 +124,9 @@ const handleAvatarHover = isHovered => {