Merge branch 'feat/captain-editor-integration' into feat/CW-5648

This commit is contained in:
Shivam Mishra
2025-12-15 18:47:10 +05:30
committed by GitHub
29 changed files with 1050 additions and 244 deletions
@@ -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"
@@ -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 || ''"
/>
<AttachmentPreviews
@@ -1,122 +1,49 @@
<script setup>
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;
};
</script>
<template>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<template v-if="isEmailOrWebWidgetInbox">
<Editor
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/>
</template>
<template v-else>
<TextArea
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
:custom-text-area-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
auto-height
allow-signature
:signature="messageSignature"
:send-with-signature="sendWithSignature"
>
<CannedResponse
v-if="state.showMentions && state.hasSlashCommand"
v-on-clickaway="hideMention"
class="normal-editor__canned-box"
:search-key="state.mentionSearchKey"
@replace="replaceText"
/>
</TextArea>
</template>
<Editor
:key="editorKey"
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
/>
</div>
</template>
@@ -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);
}
@@ -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;
}
</style>
@@ -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,
},
];
+6 -6
View File
@@ -231,7 +231,12 @@ export const MARKDOWN_PATTERNS = [
type: 'em', // PM: em, eg: *italic* or _italic_
patterns: [
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
{ pattern: /(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, replacement: '$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
{
pattern: /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
replacement: '$1',
},
],
},
{
@@ -248,11 +253,6 @@ export const MARKDOWN_PATTERNS = [
},
];
export const CHANNEL_WITH_RICH_SIGNATURE = [
'Channel::Email',
'Channel::WebWidget',
];
// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
+86 -25
View File
@@ -5,11 +5,8 @@ import {
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import {
FORMATTING,
MARKDOWN_PATTERNS,
CHANNEL_WITH_RICH_SIGNATURE,
} from 'dashboard/constants/editor';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
/**
@@ -35,6 +32,56 @@ export function extractTextFromMarkdown(markdown) {
.trim(); // Trim any extra space
}
/**
* Strip unsupported markdown formatting based on channel capabilities.
*
* @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting
* @returns {string} - The markdown with unsupported formatting removed
*/
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const has = (arr, key) => 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(
+5
View File
@@ -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',
@@ -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'
@@ -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",
@@ -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);
@@ -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',
};
@@ -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)
@@ -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
@@ -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')
@@ -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
@@ -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
@@ -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
+2 -1
View File
@@ -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!]
@@ -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:
<tone_instruction>
%s
</tone_instruction>
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.
+41 -18
View File
@@ -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')
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -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) }
@@ -241,10 +241,37 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result).to include('<a href="https://example.com">link text</a>')
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