diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index ff1c616de..90b7a0c31 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -24,6 +24,7 @@ const props = defineProps({ allowSignature: { type: Boolean, default: false }, sendWithSignature: { type: Boolean, default: false }, channelType: { type: String, default: '' }, + medium: { type: String, default: '' }, }); const emit = defineEmits(['update:modelValue']); @@ -106,6 +107,7 @@ watch( :allow-signature="allowSignature" :send-with-signature="sendWithSignature" :channel-type="channelType" + :medium="medium" @input="handleInput" @focus="handleFocus" @blur="handleBlur" diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 52ca95a07..74836219b 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -6,6 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { appendSignature, removeSignature, + getEffectiveChannelType, } from 'dashboard/helper/editorHelper'; import { buildContactableInboxesList, @@ -86,6 +87,12 @@ const whatsappMessageTemplates = computed(() => const inboxChannelType = computed(() => props.targetInbox?.channelType || ''); +const inboxMedium = computed(() => props.targetInbox?.medium || ''); + +const effectiveChannelType = computed(() => + getEffectiveChannelType(inboxChannelType.value, inboxMedium.value) +); + const validationRules = computed(() => ({ selectedContact: { required }, targetInbox: { required }, @@ -193,6 +200,7 @@ const setSelectedContact = async ({ value, action, ...rest }) => { const handleInboxAction = ({ value, action, ...rest }) => { v$.value.$reset(); + state.message = ''; emit('updateTargetInbox', { ...rest }); showInboxesDropdown.value = false; state.attachedFiles = []; @@ -202,21 +210,27 @@ const removeSignatureFromMessage = () => { // Always remove the signature from message content when inbox/contact is removed // to ensure no leftover signature content remains if (props.messageSignature) { - state.message = removeSignature(state.message, props.messageSignature); + state.message = removeSignature( + state.message, + props.messageSignature, + effectiveChannelType.value + ); } }; const removeTargetInbox = value => { v$.value.$reset(); removeSignatureFromMessage(); + state.message = ''; emit('updateTargetInbox', value); state.attachedFiles = []; }; const clearSelectedContact = () => { - emit('clearSelectedContact'); - state.attachedFiles = []; removeSignatureFromMessage(); + emit('clearSelectedContact'); + state.message = ''; + state.attachedFiles = []; }; const onClickInsertEmoji = emoji => { @@ -227,12 +241,16 @@ const handleAddSignature = signature => { state.message = appendSignature( state.message, signature, - inboxChannelType.value + effectiveChannelType.value ); }; const handleRemoveSignature = signature => { - state.message = removeSignature(state.message, signature); + state.message = removeSignature( + state.message, + signature, + effectiveChannelType.value + ); }; const handleAttachFile = files => { @@ -356,10 +374,10 @@ const shouldShowMessageEditor = computed(() => { v-model="state.message" :message-signature="messageSignature" :send-with-signature="sendWithSignature" - :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :has-errors="validationStates.isMessageInvalid" :has-attachments="state.attachedFiles.length > 0" :channel-type="inboxChannelType" + :medium="targetInbox?.medium || ''" /> -import { ref, watch, nextTick } from 'vue'; +import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; -import { - appendSignature, - removeSignature, -} from 'dashboard/helper/editorHelper'; import Editor from 'dashboard/components-next/Editor/Editor.vue'; -import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; -import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue'; const props = defineProps({ - isEmailOrWebWidgetInbox: { type: Boolean, required: true }, hasErrors: { type: Boolean, default: false }, hasAttachments: { type: Boolean, default: false }, sendWithSignature: { type: Boolean, default: false }, messageSignature: { type: String, default: '' }, channelType: { type: String, default: '' }, + medium: { type: String, default: '' }, }); +const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`); + const { t } = useI18n(); const modelValue = defineModel({ type: String, default: '', }); - -const state = ref({ - hasSlashCommand: false, - showMentions: false, - mentionSearchKey: '', -}); - -watch( - modelValue, - newValue => { - if (props.isEmailOrWebWidgetInbox) return; - - const bodyWithoutSignature = newValue - ? removeSignature(newValue, props.messageSignature) - : ''; - - // Check if message starts with slash - const startsWithSlash = bodyWithoutSignature.startsWith('/'); - - // Update slash command and mentions state - state.value = { - ...state.value, - hasSlashCommand: startsWithSlash, - showMentions: startsWithSlash, - mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '', - }; - }, - { immediate: true } -); - -const hideMention = () => { - state.value.showMentions = false; -}; - -const replaceText = async message => { - // Only append signature on replace if sendWithSignature is true - const finalMessage = props.sendWithSignature - ? appendSignature(message, props.messageSignature, props.channelType) - : message; - - await nextTick(); - modelValue.value = finalMessage; -}; diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 38f8a5954..f4a83de94 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -56,7 +56,7 @@ import { getFormattingForEditor, getSelectionCoords, calculateMenuPosition, - stripUnsupportedFormatting, + getEffectiveChannelType, } from 'dashboard/helper/editorHelper'; import { hasPressedEnterAndNotCmdOrShift, @@ -84,6 +84,7 @@ const props = defineProps({ // are triggered except when this flag is true allowSignature: { type: Boolean, default: false }, channelType: { type: String, default: '' }, + medium: { type: String, default: '' }, showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar focusOnMount: { type: Boolean, default: true }, }); @@ -110,20 +111,24 @@ const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB const DEFAULT_FORMATTING = 'Context::Default'; const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote'; +const effectiveChannelType = computed(() => + getEffectiveChannelType(props.channelType, props.medium) +); + const editorSchema = computed(() => { if (!props.channelType) return messageSchema; const formatType = props.isPrivate - ? PRIVATE_NOTE_FORMATTING - : props.channelType; + ? DEFAULT_FORMATTING + : effectiveChannelType.value; const formatting = getFormattingForEditor(formatType); return buildMessageSchema(formatting.marks, formatting.nodes); }); const editorMenuOptions = computed(() => { const formatType = props.isPrivate - ? PRIVATE_NOTE_FORMATTING - : props.channelType || DEFAULT_FORMATTING; + ? DEFAULT_FORMATTING + : effectiveChannelType.value || DEFAULT_FORMATTING; const formatting = getFormattingForEditor(formatType); return formatting.menu; }); @@ -326,8 +331,13 @@ function isBodyEmpty(content) { // if the signature is present, we need to remove it before checking // note that we don't update the editorView, so this is safe + // Use effective channel type to match how signature was appended const bodyWithoutSignature = props.signature - ? removeSignatureHelper(content, props.signature) + ? removeSignatureHelper( + content, + props.signature, + effectiveChannelType.value + ) : content; // trimming should remove all the whitespaces, so we can check the length @@ -405,7 +415,11 @@ function addSignature() { // see if the content is empty, if it is before appending the signature // we need to add a paragraph node and move the cursor at the start of the editor const contentWasEmpty = isBodyEmpty(content); - content = appendSignature(content, props.signature, props.channelType); + content = appendSignature( + content, + props.signature, + effectiveChannelType.value + ); // need to reload first, ensuring that the editorView is updated reloadState(content); @@ -417,7 +431,11 @@ function addSignature() { function removeSignature() { if (!props.signature) return; let content = props.modelValue; - content = removeSignatureHelper(content, props.signature); + content = removeSignatureHelper( + content, + props.signature, + effectiveChannelType.value + ); // reload the state, ensuring that the editorView is updated reloadState(content); } diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 5e33110df..c0d197fd1 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -7,7 +7,9 @@ import { useTrack } from 'dashboard/composables'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import { FEATURE_FLAGS } from 'dashboard/featureFlags'; +import CannedResponse from './CannedResponse.vue'; import ReplyToMessage from './ReplyToMessage.vue'; +import ResizableTextArea from 'shared/components/ResizableTextArea.vue'; import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue'; import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue'; import ReplyEmailHead from './ReplyEmailHead.vue'; @@ -45,6 +47,8 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; import { appendSignature, removeSignature, + getEffectiveChannelType, + extractTextFromMarkdown, } from 'dashboard/helper/editorHelper'; import { useCopilotReply } from 'dashboard/composables/useCopilotReply'; @@ -73,6 +77,8 @@ export default { QuotedEmailPreview, CopilotEditorSection, CopilotReplyBottomPanel, + ResizableTextArea, + CannedResponse, }, mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins], props: { @@ -115,6 +121,8 @@ export default { recordingAudioState: '', recordingAudioDurationText: '', replyType: REPLY_EDITOR_MODES.REPLY, + mentionSearchKey: '', + hasSlashCommand: false, bccEmails: '', ccEmails: '', toEmails: '', @@ -143,9 +151,12 @@ export default { isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', }), currentContact() { - return this.$store.getters['contacts/getContact']( - this.currentChat.meta.sender.id - ); + const senderId = this.currentChat?.meta?.sender?.id; + if (!senderId) return {}; + return this.$store.getters['contacts/getContact'](senderId); + }, + isRichEditorEnabled() { + return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox; }, shouldShowReplyToMessage() { return ( @@ -405,6 +416,19 @@ export default { isDefaultEditorMode() { return !this.showAudioRecorderEditor && !this.copilot.isActive.value; }, + showRichContentEditor() { + if (this.isOnPrivateNote || this.isRichEditorEnabled) { + return true; + } + + return false; + }, + // ensure that the signature is plain text depending on `showRichContentEditor` + signatureToApply() { + return this.showRichContentEditor + ? this.messageSignature + : extractTextFromMarkdown(this.messageSignature); + }, }, watch: { currentChat(conversation, oldConversation) { @@ -449,7 +473,25 @@ export default { this.resetRecorderAndClearAttachments(); } }, - message() { + message(updatedMessage) { + // Check if the message starts with a slash. + const bodyWithoutSignature = removeSignature( + updatedMessage, + this.signatureToApply + ); + const startsWithSlash = bodyWithoutSignature.startsWith('/'); + + // Determine if the user is potentially typing a slash command. + // This is true if the message starts with a slash and the rich content editor is not active. + this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor; + this.showMentions = this.hasSlashCommand; + + // If a slash command is active, extract the command text after the slash. + // If not, reset the mentionSearchKey. + this.mentionSearchKey = this.hasSlashCommand + ? bodyWithoutSignature.substring(1) + : ''; + // Autosave the current message draft. this.doAutoSaveDraft(); }, @@ -499,14 +541,20 @@ export default { methods: { handleInsert(article) { const { url, title } = article; - // Removing empty lines from the title - const lines = title.split('\n'); - const nonEmptyLines = lines.filter(line => line.trim() !== ''); - const filteredMarkdown = nonEmptyLines.join(' '); - emitter.emit( - BUS_EVENTS.INSERT_INTO_RICH_EDITOR, - `[${filteredMarkdown}](${url})` - ); + if (this.isRichEditorEnabled) { + // Removing empty lines from the title + const lines = title.split('\n'); + const nonEmptyLines = lines.filter(line => line.trim() !== ''); + const filteredMarkdown = nonEmptyLines.join(' '); + emitter.emit( + BUS_EVENTS.INSERT_INTO_RICH_EDITOR, + `[${filteredMarkdown}](${url})` + ); + } else { + this.addIntoEditor( + `${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}` + ); + } useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK); }, @@ -575,10 +623,26 @@ export default { if (this.isPrivate) { return message; } - + if (this.showRichContentEditor) { + const effectiveChannelType = getEffectiveChannelType( + this.channelType, + this.inbox?.medium || '' + ); + return this.sendWithSignature + ? appendSignature( + message, + this.messageSignature, + effectiveChannelType + ) + : removeSignature( + message, + this.messageSignature, + effectiveChannelType + ); + } return this.sendWithSignature - ? appendSignature(message, this.messageSignature, this.channelType) - : removeSignature(message, this.messageSignature); + ? appendSignature(message, this.signatureToApply) + : removeSignature(message, this.signatureToApply); }, removeFromDraft() { if (this.conversationIdByRoute) { @@ -594,6 +658,7 @@ export default { Escape: { action: () => { this.hideEmojiPicker(); + this.hideMentions(); }, allowOnFocusedInput: true, }, @@ -636,6 +701,9 @@ export default { }, onPaste(e) { const data = e.clipboardData.files; + if (!this.showRichContentEditor && data.length !== 0) { + this.$refs.messageInput?.$el?.blur(); + } if (!data.length || !data[0]) { return; } @@ -769,11 +837,19 @@ export default { // if signature is enabled, append it to the message // appendSignature ensures that the signature is not duplicated // so we don't need to check if the signature is already present - message = appendSignature( - message, - this.messageSignature, - this.channelType - ); + if (this.showRichContentEditor) { + const effectiveChannelType = getEffectiveChannelType( + this.channelType, + this.inbox?.medium || '' + ); + message = appendSignature( + message, + this.messageSignature, + effectiveChannelType + ); + } else { + message = appendSignature(message, this.signatureToApply); + } } const updatedMessage = replaceVariablesInMessage({ @@ -797,16 +873,34 @@ export default { }); if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) this.replyType = mode; - if (this.isRecordingAudio) { - this.toggleAudioRecorder(); + if (this.showRichContentEditor) { + if (this.isRecordingAudio) { + this.toggleAudioRecorder(); + } + return; } + this.$nextTick(() => this.$refs.messageInput.focus()); }, clearEditorSelection() { this.updateEditorSelectionWith = ''; }, + insertIntoTextEditor(text, selectionStart, selectionEnd) { + const { message } = this; + const newMessage = + message.slice(0, selectionStart) + + text + + message.slice(selectionEnd, message.length); + this.message = newMessage; + }, addIntoEditor(content) { - this.updateEditorSelectionWith = content; - this.onFocus(); + if (this.showRichContentEditor) { + this.updateEditorSelectionWith = content; + this.onFocus(); + } + if (!this.showRichContentEditor) { + const { selectionStart, selectionEnd } = this.$refs.messageInput.$el; + this.insertIntoTextEditor(content, selectionStart, selectionEnd); + } }, executeCopilotAction(action, data) { this.copilot.execute(action, data); @@ -815,11 +909,19 @@ export default { this.message = ''; if (this.sendWithSignature && !this.isPrivate) { // if signature is enabled, append it to the message - this.message = appendSignature( - this.message, - this.messageSignature, - this.channelType - ); + if (this.showRichContentEditor) { + const effectiveChannelType = getEffectiveChannelType( + this.channelType, + this.inbox?.medium || '' + ); + this.message = appendSignature( + this.message, + this.messageSignature, + effectiveChannelType + ); + } else { + this.message = appendSignature(this.message, this.signatureToApply); + } } this.attachedFiles = []; this.isRecordingAudio = false; @@ -854,6 +956,9 @@ export default { this.toggleEmojiPicker(); } }, + hideMentions() { + this.showMentions = false; + }, onTypingOn() { this.toggleTyping('on'); }, @@ -1307,6 +1412,10 @@ export default { .reply-box__top { @apply relative py-0 px-4 -mt-px; + + textarea { + @apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none; + } } .emoji-dialog { @@ -1326,4 +1435,9 @@ export default { @apply ltr:left-1 rtl:right-1 -bottom-2; } } + +.normal-editor__canned-box { + width: calc(100% - 2 * 1rem); + left: 1rem; +} diff --git a/app/javascript/dashboard/composables/commands/useConversationHotKeys.js b/app/javascript/dashboard/composables/commands/useConversationHotKeys.js index 30e138165..6ec1c37aa 100644 --- a/app/javascript/dashboard/composables/commands/useConversationHotKeys.js +++ b/app/javascript/dashboard/composables/commands/useConversationHotKeys.js @@ -102,8 +102,8 @@ const createNonDraftMessageAIAssistActions = (t, replyMode) => { const createDraftMessageAIAssistActions = t => { return [ { - label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'), - key: 'rephrase', + label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CONFIDENT'), + key: 'confident', icon: ICON_AI_ASSIST, }, { @@ -112,13 +112,13 @@ const createDraftMessageAIAssistActions = t => { icon: ICON_AI_GRAMMAR, }, { - label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'), - key: 'expand', + label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.PROFESSIONAL'), + key: 'professional', icon: ICON_AI_EXPAND, }, { - label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'), - key: 'shorten', + label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CASUAL'), + key: 'casual', icon: ICON_AI_SHORTEN, }, { @@ -132,8 +132,8 @@ const createDraftMessageAIAssistActions = t => { icon: ICON_AI_ASSIST, }, { - label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'), - key: 'simplify', + label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.STRAIGHTFORWARD'), + key: 'straightforward', icon: ICON_AI_ASSIST, }, ]; diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index c788101bc..d9015b063 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -231,7 +231,12 @@ export const MARKDOWN_PATTERNS = [ type: 'em', // PM: em, eg: *italic* or _italic_ patterns: [ { pattern: /(? arr.includes(key); + + // Define stripping rules: [condition, pattern, replacement] + const rules = [ + [!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''], + [!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'], + [!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''], + [!has(marks, 'code'), /`([^`]+)`/g, '$1'], + [!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'], + [!has(marks, 'strong'), /__([^_]+)__/g, '$1'], + [!has(marks, 'em'), /\*([^*]+)\*/g, '$1'], + // Match _text_ only at word boundaries (whitespace/string start/end) + // Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names + [ + !has(marks, 'em'), + /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g, + '$1', + ], + [!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'], + [!has(nodes, 'blockquote'), /^>\s?/gm, ''], + [!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''], + [!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''], + ]; + + const result = rules.reduce( + (text, [shouldStrip, pattern, replacement]) => + shouldStrip ? text.replace(pattern, replacement) : text, + markdown + ); + + return result + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .join('\n') + .replace(/\n{2,}/g, '\n') + .trim(); +} + /** * The delimiter used to separate the signature from the rest of the body. * @type {string} @@ -97,29 +144,36 @@ export function findSignatureInBody(body, signature) { } /** - * Checks if the channel supports image signatures. + * Gets the effective channel type for formatting purposes. + * For Twilio channels, returns WhatsApp or Twilio based on medium. * - * @param {string} channelType - The channel type. - * @returns {boolean} - True if the channel supports image signatures. + * @param {string} channelType - The channel type + * @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp) + * @returns {string} - The effective channel type for formatting */ -export function supportsImageSignature(channelType) { - return CHANNEL_WITH_RICH_SIGNATURE.includes(channelType); +export function getEffectiveChannelType(channelType, medium) { + if (channelType === INBOX_TYPES.TWILIO) { + return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP + ? INBOX_TYPES.WHATSAPP + : INBOX_TYPES.TWILIO; + } + return channelType; } /** * Appends the signature to the body, separated by the signature delimiter. - * Automatically strips images for channels that don't support image signatures. + * Automatically strips unsupported formatting based on channel capabilities. * * @param {string} body - The body to append the signature to. * @param {string} signature - The signature to append. - * @param {string} channelType - Optional. The channel type to determine if images should be stripped. + * @param {string} channelType - Optional. The effective channel type to determine supported formatting. + * For Twilio channels, pass the result of getEffectiveChannelType(). * @returns {string} - The body with the signature appended. */ export function appendSignature(body, signature, channelType) { - // For channels that don't support images, strip markdown formatting - const shouldStripImages = channelType && !supportsImageSignature(channelType); - const preparedSignature = shouldStripImages - ? extractTextFromMarkdown(signature) + // Strip only unsupported formatting based on channel capabilities + const preparedSignature = channelType + ? stripUnsupportedSignatureMarkdown(signature, channelType) : signature; const cleanedSignature = cleanSignature(preparedSignature); // if signature is already present, return body @@ -132,21 +186,28 @@ export function appendSignature(body, signature, channelType) { /** * Removes the signature from the body, along with the signature delimiter. - * Tries to find both the original signature and the stripped version (for non-image channels). + * Tries to find both the original signature and the stripped version. * * @param {string} body - The body to remove the signature from. * @param {string} signature - The signature to remove. + * @param {string} channelType - Optional. The effective channel type for channel-specific stripping. + * For Twilio channels, pass the result of getEffectiveChannelType(). * @returns {string} - The body with the signature removed. */ -export function removeSignature(body, signature) { - // Build list of signatures to try: original first, then stripped version - // Always try both to handle cases where channelType is unknown or inbox is being removed +export function removeSignature(body, signature, channelType) { + // Build list of signatures to try: original, channel-stripped, and fully stripped const cleanedSignature = cleanSignature(signature); - const strippedSignature = cleanSignature(extractTextFromMarkdown(signature)); - const signaturesToTry = - cleanedSignature === strippedSignature - ? [cleanedSignature] - : [cleanedSignature, strippedSignature]; + const channelStripped = channelType + ? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType)) + : null; + const fullyStripped = cleanSignature(extractTextFromMarkdown(signature)); + + // Try signatures in order: original → channel-specific → fully stripped + const signaturesToTry = [ + cleanedSignature, + channelStripped, + fullyStripped, + ].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates // Find the first matching signature const signatureIndex = signaturesToTry.reduce( diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index 501375ab7..05823e8a7 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -13,6 +13,11 @@ export const INBOX_TYPES = { VOICE: 'Channel::Voice', }; +export const TWILIO_CHANNEL_MEDIUM = { + WHATSAPP: 'whatsapp', + SMS: 'sms', +}; + const INBOX_ICON_MAP_FILL = { [INBOX_TYPES.WEB]: 'i-ri-global-fill', [INBOX_TYPES.FB]: 'i-ri-messenger-fill', diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 07dfbf1e5..1ae765ca7 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -5,7 +5,7 @@ import { replaceSignature, cleanSignature, extractTextFromMarkdown, - supportsImageSignature, + stripUnsupportedSignatureMarkdown, insertAtCursor, findNodeToInsertImage, setURLWithQueryAndSize, @@ -145,10 +145,63 @@ describe('appendSignature', () => { }); }); +describe('stripUnsupportedSignatureMarkdown', () => { + const richSignature = + '**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)'; + + it('keeps all formatting for Email channel (supports image, link, strong, em)', () => { + const result = stripUnsupportedSignatureMarkdown( + richSignature, + 'Channel::Email' + ); + expect(result).toContain('**Bold**'); + expect(result).toContain('_italic_'); + expect(result).toContain('[link](http://example.com)'); + expect(result).toContain('![](http://localhost:3000/image.png)'); + }); + it('strips images but keeps bold/italic for Api channel', () => { + const result = stripUnsupportedSignatureMarkdown( + richSignature, + 'Channel::Api' + ); + expect(result).toContain('**Bold**'); + expect(result).toContain('_italic_'); + expect(result).toContain('link'); // link text kept + expect(result).not.toContain('[link]('); // link syntax removed + expect(result).not.toContain('![]('); // image removed + }); + it('strips images but keeps bold/italic/link for Telegram channel', () => { + const result = stripUnsupportedSignatureMarkdown( + richSignature, + 'Channel::Telegram' + ); + expect(result).toContain('**Bold**'); + expect(result).toContain('_italic_'); + expect(result).toContain('[link](http://example.com)'); + expect(result).not.toContain('![]('); + }); + it('strips all formatting for SMS channel', () => { + const result = stripUnsupportedSignatureMarkdown( + richSignature, + 'Channel::Sms' + ); + expect(result).toContain('Bold'); + expect(result).toContain('italic'); + expect(result).toContain('link'); + expect(result).not.toContain('**'); + expect(result).not.toContain('_'); + expect(result).not.toContain('['); + expect(result).not.toContain('![]('); + }); + it('returns empty string for empty input', () => { + expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe(''); + expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe(''); + }); +}); + describe('appendSignature with channelType', () => { const signatureWithImage = 'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)'; - const strippedSignature = 'Thanks'; it('keeps images for Email channel', () => { const result = appendSignature( @@ -166,24 +219,31 @@ describe('appendSignature with channelType', () => { ); expect(result).toContain('![](http://localhost:3000/image.png'); }); - it('strips images for Api channel', () => { + it('strips images but keeps text for Api channel', () => { const result = appendSignature('Hello', signatureWithImage, 'Channel::Api'); expect(result).not.toContain('![]('); - expect(result).toContain(strippedSignature); + expect(result).toContain('Thanks'); }); - it('strips images for WhatsApp channel', () => { + it('strips images but keeps text for WhatsApp channel', () => { const result = appendSignature( 'Hello', signatureWithImage, 'Channel::Whatsapp' ); expect(result).not.toContain('![]('); - expect(result).toContain(strippedSignature); + expect(result).toContain('Thanks'); }); it('keeps images when channelType is not provided', () => { const result = appendSignature('Hello', signatureWithImage); expect(result).toContain('![](http://localhost:3000/image.png'); }); + it('keeps bold/italic for channels that support them', () => { + const boldSignature = '**Bold** *italic* Thanks'; + const result = appendSignature('Hello', boldSignature, 'Channel::Api'); + // Api supports strong and em + expect(result).toContain('**Bold**'); + expect(result).toContain('*italic*'); + }); }); describe('cleanSignature', () => { @@ -331,24 +391,6 @@ describe('extractTextFromMarkdown', () => { }); }); -describe('supportsImageSignature', () => { - it('returns true for Email channel', () => { - expect(supportsImageSignature('Channel::Email')).toBe(true); - }); - it('returns true for WebWidget channel', () => { - expect(supportsImageSignature('Channel::WebWidget')).toBe(true); - }); - it('returns false for Api channel', () => { - expect(supportsImageSignature('Channel::Api')).toBe(false); - }); - it('returns false for WhatsApp channel', () => { - expect(supportsImageSignature('Channel::Whatsapp')).toBe(false); - }); - it('returns false for Telegram channel', () => { - expect(supportsImageSignature('Channel::Telegram')).toBe(false); - }); -}); - describe('insertAtCursor', () => { it('should return undefined if editorView is not provided', () => { const result = insertAtCursor(undefined, schema.text('Hello'), 0); @@ -884,6 +926,26 @@ describe('stripUnsupportedFormatting', () => { ); }); + it('preserves underscores in URLs and mid-word positions', () => { + // Underscores in URLs should not be stripped as italic formatting + expect( + stripUnsupportedFormatting( + 'https://www.chatwoot.com/new_first_second-third/ssd', + emptySchema + ) + ).toBe('https://www.chatwoot.com/new_first_second-third/ssd'); + + // Underscores in variable names should not be stripped + expect( + stripUnsupportedFormatting('some_variable_name', emptySchema) + ).toBe('some_variable_name'); + + // But actual italic formatting with spaces should still be stripped + expect( + stripUnsupportedFormatting('hello _world_ there', emptySchema) + ).toBe('hello world there'); + }); + it('strips inline code formatting', () => { expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe( 'inline code' diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 039acb173..27e4cfbb6 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -145,7 +145,11 @@ "EXPAND": "Expand", "MAKE_FRIENDLY": "Change message tone to friendly", "MAKE_FORMAL": "Use formal tone", - "SIMPLIFY": "Simplify" + "SIMPLIFY": "Simplify", + "CONFIDENT": "Use confident tone", + "PROFESSIONAL": "Use professional tone", + "CASUAL": "Use casual tone", + "STRAIGHTFORWARD": "Use straightforward tone" }, "REPLY_OPTIONS": { "IMPROVE_REPLY": "Improve reply", diff --git a/app/javascript/shared/components/ResizableTextArea.vue b/app/javascript/shared/components/ResizableTextArea.vue index 4dda62cf9..78318fb49 100644 --- a/app/javascript/shared/components/ResizableTextArea.vue +++ b/app/javascript/shared/components/ResizableTextArea.vue @@ -111,10 +111,15 @@ export default { // watcher, this means that if the value is true, the signature // is supposed to be added, else we remove it. toggleSignatureInEditor(signatureEnabled) { - const valueWithSignature = signatureEnabled + let valueWithSignature = signatureEnabled ? appendSignature(this.modelValue, this.cleanedSignature) : removeSignature(this.modelValue, this.cleanedSignature); + // Clean up whitespace when removing signature from empty body + if (!signatureEnabled && !valueWithSignature.trim()) { + valueWithSignature = ''; + } + this.$emit('update:modelValue', valueWithSignature); this.$emit('input', valueWithSignature); diff --git a/app/javascript/shared/constants/openai.js b/app/javascript/shared/constants/openai.js index 8c5adb875..c7fa7db0c 100644 --- a/app/javascript/shared/constants/openai.js +++ b/app/javascript/shared/constants/openai.js @@ -8,4 +8,8 @@ export const OPEN_AI_OPTIONS = { SIMPLIFY: 'simplify', REPLY_SUGGESTION: 'reply_suggestion', SUMMARIZE: 'summarize', + CASUAL: 'casual', + PROFESSIONAL: 'professional', + STRAIGHTFORWARD: 'straightforward', + CONFIDENT: 'confident', }; diff --git a/app/services/message_templates/template/out_of_office.rb b/app/services/message_templates/template/out_of_office.rb index b6fd1b7a1..f3c8c24fa 100644 --- a/app/services/message_templates/template/out_of_office.rb +++ b/app/services/message_templates/template/out_of_office.rb @@ -1,6 +1,14 @@ class MessageTemplates::Template::OutOfOffice pattr_initialize [:conversation!] + def self.perform_if_applicable(conversation) + inbox = conversation.inbox + return unless inbox.out_of_office? + return if inbox.out_of_office_message.blank? + + new(conversation: conversation).perform + end + def perform ActiveRecord::Base.transaction do conversation.messages.create!(out_of_office_message_params) diff --git a/app/services/messages/markdown_renderer_service.rb b/app/services/messages/markdown_renderer_service.rb index a90cfca06..00a731a6d 100644 --- a/app/services/messages/markdown_renderer_service.rb +++ b/app/services/messages/markdown_renderer_service.rb @@ -96,10 +96,10 @@ class Messages::MarkdownRendererService restore_multiple_newlines(result) end - # Preserve multiple consecutive newlines (3+) by replacing them with placeholders - # Standard markdown treats 2 newlines as paragraph break, we preserve 3+ + # Preserve multiple consecutive newlines (2+) by replacing them with placeholders + # Standard markdown treats 2 newlines as paragraph break which collapses to 1 newline, we preserve 2+ def preserve_multiple_newlines(content) - content.gsub(/\n{3,}/) do |match| + content.gsub(/\n{2,}/) do |match| "{{PRESERVE_#{match.length}_NEWLINES}}" end end diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index f218ff68c..9f8acc474 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -87,10 +87,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob I18n.with_locale(@assistant.account.locale) do create_handoff_message @conversation.bot_handoff! + send_out_of_office_message_if_applicable end end end + def send_out_of_office_message_if_applicable + ::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation) + end + def create_handoff_message create_outgoing_message( @assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff') diff --git a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb index ac1c3a95d..5c9a8a184 100644 --- a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb +++ b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb @@ -9,6 +9,24 @@ module Enterprise::MessageTemplates::HookExecutionService schedule_captain_response end + def should_send_greeting? + return false if captain_handling_conversation? + + super + end + + def should_send_out_of_office_message? + return false if captain_handling_conversation? + + super + end + + def should_send_email_collect? + return false if captain_handling_conversation? + + super + end + private def schedule_captain_response @@ -46,5 +64,14 @@ module Enterprise::MessageTemplates::HookExecutionService content: 'Transferring to another agent for further assistance.' ) conversation.bot_handoff! + send_out_of_office_message_after_handoff + end + + def send_out_of_office_message_after_handoff + ::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation) + end + + def captain_handling_conversation? + conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present? end end diff --git a/enterprise/lib/captain/tools/handoff_tool.rb b/enterprise/lib/captain/tools/handoff_tool.rb index 49f7c5a65..797f248ff 100644 --- a/enterprise/lib/captain/tools/handoff_tool.rb +++ b/enterprise/lib/captain/tools/handoff_tool.rb @@ -36,6 +36,13 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool # Trigger the bot handoff (sets status to open + dispatches events) conversation.bot_handoff! + + # Send out of office message if applicable (since template messages were suppressed while Captain was handling) + send_out_of_office_message_if_applicable(conversation) + end + + def send_out_of_office_message_if_applicable(conversation) + ::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation) end # TODO: Future enhancement - Add team assignment capability diff --git a/enterprise/lib/enterprise/integrations/openai_processor_service.rb b/enterprise/lib/enterprise/integrations/openai_processor_service.rb index 5a98ad4c4..b09c7ca7c 100644 --- a/enterprise/lib/enterprise/integrations/openai_processor_service.rb +++ b/enterprise/lib/enterprise/integrations/openai_processor_service.rb @@ -1,6 +1,6 @@ module Enterprise::Integrations::OpenaiProcessorService - ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion label_suggestion fix_spelling_grammar shorten expand - make_friendly make_formal simplify].freeze + ALLOWED_EVENT_NAMES = %w[summarize reply_suggestion label_suggestion fix_spelling_grammar + make_friendly make_formal casual professional confident straightforward].freeze CACHEABLE_EVENTS = %w[label_suggestion].freeze def label_suggestion_message diff --git a/lib/integrations/llm_base_service.rb b/lib/integrations/llm_base_service.rb index ca9459fc8..4652ad76f 100644 --- a/lib/integrations/llm_base_service.rb +++ b/lib/integrations/llm_base_service.rb @@ -7,7 +7,8 @@ class Integrations::LlmBaseService # 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe) TOKEN_LIMIT = 400_000 GPT_MODEL = Llm::Config::DEFAULT_MODEL - ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze + ALLOWED_EVENT_NAMES = %w[summarize reply_suggestion fix_spelling_grammar casual professional make_friendly make_formal confident + straightforward].freeze CACHEABLE_EVENTS = %w[].freeze pattr_initialize [:hook!, :event!] diff --git a/lib/integrations/openai/openai_prompts/tone_rewrite.txt b/lib/integrations/openai/openai_prompts/tone_rewrite.txt new file mode 100644 index 000000000..200a8c652 --- /dev/null +++ b/lib/integrations/openai/openai_prompts/tone_rewrite.txt @@ -0,0 +1,19 @@ +You are an AI writing assistant integrated into Chatwoot, an omnichannel customer support platform. Your task is to rewrite customer support message to match a specific tone while preserving the original meaning and intent. + +Here is the tone to apply to the message you will receive: + +%s + + +Your task is to rewrite the message according to the specified tone instructions. + +Important guidelines: +- Preserve the core meaning and all important information from the original message +- Keep the rewritten message concise and appropriate for customer support +- Maintain helpfulness and respect regardless of tone +- Do not add information that wasn't in the original message +- Do not remove critical details or instructions +- Ensure that the reply should be in user language. + +Output only the rewritten message without any preamble, tags or explanation. + diff --git a/lib/integrations/openai/processor_service.rb b/lib/integrations/openai/processor_service.rb index 2f0180701..0f648cf22 100644 --- a/lib/integrations/openai/processor_service.rb +++ b/lib/integrations/openai/processor_service.rb @@ -1,5 +1,4 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService - AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze def reply_suggestion_message make_api_call(reply_suggestion_body) @@ -9,39 +8,39 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService make_api_call(summarize_body) end - def rephrase_message - make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please rephrase the following response. " \ - "#{LANGUAGE_INSTRUCTION}")) + def confident_message + tone_instruction = determine_tone_instruction('confident') + make_api_call(build_api_call_body(tone_rewrite_prompt(tone_instruction))) end def fix_spelling_grammar_message - make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please fix the spelling and grammar of the following response. " \ + make_api_call(build_api_call_body('Please fix the spelling and grammar of the following response. ' \ "#{LANGUAGE_INSTRUCTION}")) end - def shorten_message - make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please shorten the following response. " \ - "#{LANGUAGE_INSTRUCTION}")) + def straightforward_message + tone_instruction = determine_tone_instruction('straightforward') + make_api_call(build_api_call_body(tone_rewrite_prompt(tone_instruction))) end - def expand_message - make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please expand the following response. " \ - "#{LANGUAGE_INSTRUCTION}")) + def casual_message + tone_instruction = determine_tone_instruction('casual') + make_api_call(build_api_call_body(tone_rewrite_prompt(tone_instruction))) end def make_friendly_message - make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more friendly. " \ - "#{LANGUAGE_INSTRUCTION}")) + tone_instruction = determine_tone_instruction('friendly') + make_api_call(build_api_call_body(tone_rewrite_prompt(tone_instruction))) end def make_formal_message - make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more formal. " \ - "#{LANGUAGE_INSTRUCTION}")) + tone_instruction = determine_tone_instruction('formal') + make_api_call(build_api_call_body(tone_rewrite_prompt(tone_instruction))) end - def simplify_message - make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please simplify the following response. " \ - "#{LANGUAGE_INSTRUCTION}")) + def professional_message + tone_instruction = determine_tone_instruction('professional') + make_api_call(build_api_call_body(tone_rewrite_prompt(tone_instruction))) end private @@ -51,6 +50,10 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService Rails.root.join(path, "#{file_name}.txt").read end + def tone_rewrite_prompt(tone_instruction) + format(prompt_from_file('tone_rewrite'), tone_instruction) + end + def build_api_call_body(system_content, user_content = event['data']['content']) { model: GPT_MODEL, @@ -133,6 +136,26 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService ].concat(conversation_messages(in_array_format: true)) }.to_json end + + def determine_tone_instruction(tone) + case tone + when 'friendly' + 'Warm, approachable, and personable. Use conversational language, positive words, and show empathy. ' \ + 'May include phrases like \"Happy to help!\" or \"I\'d be glad to...\"' + when 'confident' + 'Assertive and assured. Use definitive language, avoid hedging words like \"maybe\" or \"I think\". ' \ + 'Be direct and authoritative while remaining helpful.' + when 'straightforward' + 'Clear, direct, and to-the-point. Remove unnecessary words, get straight to the information or solution. No fluff or extra pleasantries.' + when 'casual' + 'Relaxed and informal. Use contractions, simpler words, and a conversational style. Friendly but less formal than professional tone.' + when 'professional' + 'Formal, polished, and business-appropriate. Use complete sentences, proper grammar, ' \ + 'and maintain respectful distance. Avoid slang or overly casual language.' + else + determine_tone_instruction('friendly') + end + end end Integrations::Openai::ProcessorService.prepend_mod_with('Integrations::OpenaiProcessorService') diff --git a/spec/controllers/linear/callbacks_controller_spec.rb b/spec/controllers/linear/callbacks_controller_spec.rb index b3245fbc5..66018776c 100644 --- a/spec/controllers/linear/callbacks_controller_spec.rb +++ b/spec/controllers/linear/callbacks_controller_spec.rb @@ -3,7 +3,8 @@ require 'rails_helper' RSpec.describe Linear::CallbacksController, type: :request do let(:account) { create(:account) } let(:code) { SecureRandom.hex(10) } - let(:state) { SecureRandom.hex(10) } + let(:client_secret) { 'test_linear_secret' } + let(:state) { JWT.encode({ sub: account.id, iat: Time.current.to_i }, client_secret, 'HS256') } let(:linear_redirect_uri) { "#{ENV.fetch('FRONTEND_URL', '')}/app/accounts/#{account.id}/settings/integrations/linear" } describe 'GET /linear/callback' do @@ -19,10 +20,9 @@ RSpec.describe Linear::CallbacksController, type: :request do before do stub_const('ENV', ENV.to_hash.merge('FRONTEND_URL' => 'http://www.example.com')) - - controller = described_class.new - allow(controller).to receive(:verify_linear_token).with(state).and_return(account.id) - allow(described_class).to receive(:new).and_return(controller) + allow(GlobalConfigService).to receive(:load).and_call_original + allow(GlobalConfigService).to receive(:load).with('LINEAR_CLIENT_SECRET', nil).and_return(client_secret) + allow(GlobalConfigService).to receive(:load).with('LINEAR_CLIENT_ID', nil).and_return('test_client_id') end context 'when successful' do diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb index 565fc26cf..08a77626a 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -229,4 +229,106 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do expect(described_class::MAX_MESSAGE_LENGTH).to eq(10_000) end end + + describe 'out of office message after handoff' do + let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) } + let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) } + + before do + create(:message, conversation: conversation, content: 'Hello', message_type: :incoming) + allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service) + allow(account).to receive(:feature_enabled?).and_return(false) + allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false) + end + + context 'when handoff occurs outside business hours' do + before do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: 'We are currently closed. Please leave your email.' + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + closed_all_day: true, + open_all_day: false + ) + allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' }) + end + + it 'sends out of office message after handoff' do + expect do + described_class.perform_now(conversation, assistant) + end.to change { conversation.messages.template.count }.by(1) + + expect(conversation.reload.status).to eq('open') + ooo_message = conversation.messages.template.last + expect(ooo_message.content).to eq('We are currently closed. Please leave your email.') + end + end + + context 'when handoff occurs within business hours' do + before do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: 'We are currently closed.' + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + open_all_day: true, + closed_all_day: false + ) + allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' }) + end + + it 'does not send out of office message after handoff' do + expect do + described_class.perform_now(conversation, assistant) + end.not_to(change { conversation.messages.template.count }) + + expect(conversation.reload.status).to eq('open') + end + end + + context 'when handoff occurs due to error outside business hours' do + before do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: 'We are currently closed.' + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + closed_all_day: true, + open_all_day: false + ) + allow(mock_llm_chat_service).to receive(:generate_response).and_raise(StandardError, 'API error') + end + + it 'sends out of office message after error-triggered handoff' do + expect do + described_class.perform_now(conversation, assistant) + end.to change { conversation.messages.template.count }.by(1) + + expect(conversation.reload.status).to eq('open') + ooo_message = conversation.messages.template.last + expect(ooo_message.content).to eq('We are currently closed.') + end + end + + context 'when no out of office message is configured' do + before do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: nil + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + closed_all_day: true, + open_all_day: false + ) + allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' }) + end + + it 'does not send out of office message' do + expect do + described_class.perform_now(conversation, assistant) + end.not_to(change { conversation.messages.template.count }) + end + end + end end diff --git a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb index 16b46c08a..ef56ae3bb 100644 --- a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb @@ -163,4 +163,66 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do expect(tool.active?).to be true end end + + describe 'out of office message after handoff' do + context 'when outside business hours' do + before do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: 'We are currently closed. Please leave your email.' + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + closed_all_day: true, + open_all_day: false + ) + end + + it 'sends out of office message after handoff' do + expect do + tool.perform(tool_context, reason: 'Customer needs help') + end.to change { conversation.messages.template.count }.by(1) + + ooo_message = conversation.messages.template.last + expect(ooo_message.content).to eq('We are currently closed. Please leave your email.') + end + end + + context 'when within business hours' do + before do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: 'We are currently closed.' + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + open_all_day: true, + closed_all_day: false + ) + end + + it 'does not send out of office message after handoff' do + expect do + tool.perform(tool_context, reason: 'Customer needs help') + end.not_to(change { conversation.messages.template.count }) + end + end + + context 'when no out of office message is configured' do + before do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: nil + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + closed_all_day: true, + open_all_day: false + ) + end + + it 'does not send out of office message' do + expect do + tool.perform(tool_context, reason: 'Customer needs help') + end.not_to(change { conversation.messages.template.count }) + end + end + end end diff --git a/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb b/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb new file mode 100644 index 000000000..9074baa57 --- /dev/null +++ b/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb @@ -0,0 +1,295 @@ +require 'rails_helper' + +RSpec.describe MessageTemplates::HookExecutionService do + let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, inbox: inbox, account: account, contact: contact, status: :pending) } + let(:assistant) { create(:captain_assistant, account: account) } + + before do + create(:captain_inbox, captain_assistant: assistant, inbox: inbox) + end + + context 'when captain assistant is configured' do + context 'when within business hours' do + before do + inbox.update!(working_hours_enabled: true) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + open_all_day: true, + closed_all_day: false + ) + end + + it 'schedules captain response job for incoming messages on pending conversations' do + expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant) + + create(:message, conversation: conversation, message_type: :incoming) + end + end + + context 'when outside business hours' do + before do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: 'We are currently closed' + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + closed_all_day: true, + open_all_day: false + ) + end + + it 'schedules captain response job outside business hours (Captain always responds when configured)' do + expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant) + + create(:message, conversation: conversation, message_type: :incoming) + end + + it 'performs captain handoff when quota is exceeded (OOO template will kick in after handoff)' do + account.update!( + limits: { 'captain_responses' => 100 }, + custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100) + ) + + create(:message, conversation: conversation, message_type: :incoming) + + expect(conversation.reload.status).to eq('open') + end + + it 'does not send out of office message when Captain is handling' do + out_of_office_service = instance_double(MessageTemplates::Template::OutOfOffice) + allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) + allow(out_of_office_service).to receive(:perform).and_return(true) + + create(:message, conversation: conversation, message_type: :incoming) + + expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new) + end + end + + context 'when business hours are not enabled' do + before do + inbox.update!(working_hours_enabled: false) + end + + it 'schedules captain response job regardless of time' do + expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant) + + create(:message, conversation: conversation, message_type: :incoming) + end + end + + context 'when captain quota is exceeded within business hours' do + before do + inbox.update!(working_hours_enabled: true) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + open_all_day: true, + closed_all_day: false + ) + + account.update!( + limits: { 'captain_responses' => 100 }, + custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100) + ) + end + + it 'performs handoff within business hours when quota exceeded' do + create(:message, conversation: conversation, message_type: :incoming) + + expect(conversation.reload.status).to eq('open') + end + end + end + + context 'when no captain assistant is configured' do + before do + CaptainInbox.where(inbox: inbox).destroy_all + end + + it 'does not schedule captain response job' do + expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later) + + create(:message, conversation: conversation, message_type: :incoming) + end + end + + context 'when conversation is not pending' do + before do + conversation.update!(status: :open) + end + + it 'does not schedule captain response job' do + expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later) + + create(:message, conversation: conversation, message_type: :incoming) + end + end + + context 'when message is outgoing' do + it 'does not schedule captain response job' do + expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later) + + create(:message, conversation: conversation, message_type: :outgoing) + end + end + + context 'when greeting and out of office messages with Captain enabled' do + context 'when conversation is pending (Captain is handling)' do + before do + conversation.update!(status: :pending) + end + + it 'does not create greeting message in conversation' do + inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false) + + expect do + create(:message, conversation: conversation, message_type: :incoming) + end.not_to(change { conversation.reload.messages.template.count }) + end + + it 'does not create out of office message in conversation' do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: 'We are currently closed', + enable_email_collect: false + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + closed_all_day: true, + open_all_day: false + ) + + expect do + create(:message, conversation: conversation, message_type: :incoming) + end.not_to(change { conversation.reload.messages.template.count }) + end + end + + context 'when conversation is open (transferred to agent)' do + before do + conversation.update!(status: :open) + end + + it 'creates greeting message in conversation' do + inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false) + + expect do + create(:message, conversation: conversation, message_type: :incoming) + end.to change { conversation.reload.messages.template.count }.by(1) + + greeting_message = conversation.reload.messages.template.last + expect(greeting_message.content).to eq('Hello! How can we help you?') + end + + it 'creates out of office message when outside business hours' do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: 'We are currently closed', + enable_email_collect: false + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + closed_all_day: true, + open_all_day: false + ) + + expect do + create(:message, conversation: conversation, message_type: :incoming) + end.to change { conversation.reload.messages.template.count }.by(1) + + out_of_office_message = conversation.reload.messages.template.last + expect(out_of_office_message.content).to eq('We are currently closed') + end + end + end + + context 'when Captain is not configured' do + before do + CaptainInbox.where(inbox: inbox).destroy_all + end + + it 'creates greeting message in conversation' do + inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false) + + expect do + create(:message, conversation: conversation, message_type: :incoming) + end.to change { conversation.reload.messages.template.count }.by(1) + + greeting_message = conversation.reload.messages.template.last + expect(greeting_message.content).to eq('Hello! How can we help you?') + end + + it 'creates out of office message when outside business hours' do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: 'We are currently closed', + enable_email_collect: false + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + closed_all_day: true, + open_all_day: false + ) + + expect do + create(:message, conversation: conversation, message_type: :incoming) + end.to change { conversation.reload.messages.template.count }.by(1) + + out_of_office_message = conversation.reload.messages.template.last + expect(out_of_office_message.content).to eq('We are currently closed') + end + end + + context 'when Captain quota is exceeded and handoff happens' do + before do + account.update!( + limits: { 'captain_responses' => 100 }, + custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100) + ) + end + + context 'when outside business hours' do + before do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: 'We are currently closed. Please leave your email.', + enable_email_collect: false + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + closed_all_day: true, + open_all_day: false + ) + end + + it 'sends out of office message after handoff due to quota exceeded' do + expect do + create(:message, conversation: conversation, message_type: :incoming) + end.to change { conversation.messages.template.count }.by(1) + + expect(conversation.reload.status).to eq('open') + ooo_message = conversation.messages.template.last + expect(ooo_message.content).to eq('We are currently closed. Please leave your email.') + end + end + + context 'when within business hours' do + before do + inbox.update!( + working_hours_enabled: true, + out_of_office_message: 'We are currently closed.', + enable_email_collect: false + ) + inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!( + open_all_day: true, + closed_all_day: false + ) + end + + it 'does not send out of office message after handoff' do + expect do + create(:message, conversation: conversation, message_type: :incoming) + end.not_to(change { conversation.messages.template.count }) + + expect(conversation.reload.status).to eq('open') + end + end + end +end diff --git a/spec/lib/integrations/openai/processor_service_spec.rb b/spec/lib/integrations/openai/processor_service_spec.rb index 28488cc15..f9bd5531f 100644 --- a/spec/lib/integrations/openai/processor_service_spec.rb +++ b/spec/lib/integrations/openai/processor_service_spec.rb @@ -53,17 +53,23 @@ RSpec.describe Integrations::Openai::ProcessorService do it 'sets system instructions' do service.perform - expect(mock_chat).to have_received(:with_instructions).with(a_string_including('You are a helpful support agent')) + if event_name == 'fix_spelling_grammar' + expect(mock_chat).to have_received(:with_instructions) + .with(a_string_including('Please fix the spelling and grammar')) + else + expect(mock_chat).to have_received(:with_instructions) + .with(a_string_including('You are an AI writing assistant integrated into Chatwoot')) + end end end - it_behaves_like 'text transformation operation', 'rephrase' + it_behaves_like 'text transformation operation', 'confident' it_behaves_like 'text transformation operation', 'fix_spelling_grammar' - it_behaves_like 'text transformation operation', 'shorten' - it_behaves_like 'text transformation operation', 'expand' + it_behaves_like 'text transformation operation', 'casual' + it_behaves_like 'text transformation operation', 'professional' it_behaves_like 'text transformation operation', 'make_friendly' it_behaves_like 'text transformation operation', 'make_formal' - it_behaves_like 'text transformation operation', 'simplify' + it_behaves_like 'text transformation operation', 'straightforward' end describe 'conversation-based operations' do @@ -125,7 +131,7 @@ RSpec.describe Integrations::Openai::ProcessorService do end describe 'response structure' do - let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } } + let(:event) { { 'name' => 'confident', 'data' => { 'content' => 'test message' } } } context 'when response includes usage data' do before do @@ -166,10 +172,13 @@ RSpec.describe Integrations::Openai::ProcessorService do end describe 'endpoint configuration' do - let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } } + let(:event) { { 'name' => 'confident', 'data' => { 'content' => 'test message' } } } context 'without CAPTAIN_OPEN_AI_ENDPOINT configured' do - before { InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy } + before do + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy + allow(Llm::Config).to receive(:with_api_key).and_call_original + end it 'uses default OpenAI endpoint' do expect(Llm::Config).to receive(:with_api_key).with( @@ -185,6 +194,7 @@ RSpec.describe Integrations::Openai::ProcessorService do before do InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/') + allow(Llm::Config).to receive(:with_api_key).and_call_original end it 'uses custom endpoint' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 213988a0d..d263708af 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -14,7 +14,7 @@ RSpec.describe User do context 'with associations' do it { is_expected.to have_many(:accounts).through(:account_users) } it { is_expected.to have_many(:account_users) } - it { is_expected.to have_many(:assigned_conversations).class_name('Conversation').dependent(:nullify) } + it { is_expected.to have_many(:assigned_conversations).dependent(:nullify) } it { is_expected.to have_many(:inbox_members).dependent(:destroy_async) } it { is_expected.to have_many(:notification_settings).dependent(:destroy_async) } it { is_expected.to have_many(:messages) } diff --git a/spec/services/messages/markdown_renderer_service_spec.rb b/spec/services/messages/markdown_renderer_service_spec.rb index 89210dc0e..5b8e02e2f 100644 --- a/spec/services/messages/markdown_renderer_service_spec.rb +++ b/spec/services/messages/markdown_renderer_service_spec.rb @@ -241,10 +241,37 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do expect(result).to include('link text') end - it 'preserves newlines' do + it 'preserves single newlines' do content = "line 1\nline 2" result = described_class.new(content, channel_type).render expect(result).to include("\n") + expect(result).to include("line 1\nline 2") + end + + it 'preserves double newlines (paragraph breaks)' do + content = "para 1\n\npara 2" + result = described_class.new(content, channel_type).render + expect(result.scan("\n").count).to eq(2) + expect(result).to include("para 1\n\npara 2") + end + + it 'preserves multiple consecutive newlines' do + content = "para 1\n\n\n\npara 2" + result = described_class.new(content, channel_type).render + expect(result.scan("\n").count).to eq(4) + expect(result).to include("para 1\n\n\n\npara 2") + end + + it 'preserves newlines with varying amounts of whitespace between them' do + # Test with 1 space, 3 spaces, 5 spaces, and tabs to ensure it handles any amount of whitespace + content = "hello\n \n \n \n\t\nworld" + result = described_class.new(content, channel_type).render + # Whitespace-only lines are normalized, so we should have at least 5 newlines preserved + expect(result.scan("\n").count).to be >= 5 + expect(result).to include('hello') + expect(result).to include('world') + # Should not collapse to just 1-2 newlines + expect(result.scan("\n").count).to be > 3 end it 'converts strikethrough to HTML' do