diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index a300b5f59..c65a3031d 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts { redirect_uri: "#{base_url}/microsoft/callback", scope: scope, - state: state, - prompt: 'consent' + state: state } ) if redirect_url 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/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/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')" /> diff --git a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue index 86d9e979c..7758500a0 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'; @@ -11,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(); @@ -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)" diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 3da526fc0..09dc23819 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" />
-
- -
@@ -997,10 +979,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); @apply text-n-slate-11; } } - - ol li { - @apply list-item list-decimal; - } } } diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 5528a9b43..95f63f206 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', ], @@ -269,23 +271,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/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/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/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) } 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/app/services/messages/webhook_content_normalizer.rb b/app/services/messages/webhook_content_normalizer.rb index b45f83ad0..5fa66fe9c 100644 --- a/app/services/messages/webhook_content_normalizer.rb +++ b/app/services/messages/webhook_content_normalizer.rb @@ -1,10 +1,11 @@ # Strips CommonMark hard line breaks from stored markdown source (backslash before newline). # ProseMirror / the dashboard editor emits this form so soft breaks survive as markdown; # webhook consumers expect plain newlines without a visible backslash (e.g. WhatsApp gateways). +# Also strips trailing newlines introduced by TipTap/ProseMirror trailing paragraph nodes. class Messages::WebhookContentNormalizer def self.normalize(text) return text if text.blank? - text.gsub(/\\\r?\n/, "\n") + text.gsub(/\\\r?\n/, "\n").sub(/(\r?\n)+\z/, '') end end 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/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 @@ + + + + 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/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/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/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/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/public/audio/dashboard/ringtone.mp3 b/public/audio/dashboard/ringtone.mp3 new file mode 100644 index 000000000..c2af2b6d1 Binary files /dev/null and b/public/audio/dashboard/ringtone.mp3 differ diff --git a/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb index 60b05b36c..18a26a393 100644 --- a/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb @@ -43,6 +43,7 @@ RSpec.describe 'Microsoft Authorization API', type: :request do ] expect(params['scope']).to eq(expected_scope) expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"]) + expect(url).not_to match(/(?:\?|&)prompt=/) # Validate state parameter exists and can be decoded back to the account expect(params['state']).to be_present 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/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 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 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 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 diff --git a/spec/services/messages/webhook_content_normalizer_spec.rb b/spec/services/messages/webhook_content_normalizer_spec.rb new file mode 100644 index 000000000..ca5023b9d --- /dev/null +++ b/spec/services/messages/webhook_content_normalizer_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe Messages::WebhookContentNormalizer do + describe '.normalize' do + it 'returns nil unchanged' do + expect(described_class.normalize(nil)).to be_nil + end + + it 'returns blank string unchanged' do + expect(described_class.normalize('')).to eq('') + end + + it 'strips trailing newlines added by TipTap/ProseMirror' do + expect(described_class.normalize("hello\n\n\n")).to eq('hello') + end + + it 'preserves intentional trailing spaces' do + expect(described_class.normalize("hello \n\n")).to eq('hello ') + end + + it 'replaces CommonMark hard line breaks (backslash-newline) with plain newlines' do + expect(described_class.normalize("hello\\\nworld")).to eq("hello\nworld") + end + + it 'replaces CommonMark hard line breaks with CRLF with plain newlines' do + expect(described_class.normalize("hello\\\r\nworld")).to eq("hello\nworld") + end + + it 'preserves intentional internal newlines' do + expect(described_class.normalize("line one\nline two")).to eq("line one\nline two") + end + + it 'strips trailing CRLF newlines without leaving dangling carriage returns' do + expect(described_class.normalize("hello\r\n\r\n")).to eq('hello') + end + + it 'handles both hard line breaks and trailing newlines together' do + expect(described_class.normalize("hello\\\nworld\n\n\n")).to eq("hello\nworld") + end + end +end 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 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))',